Author SHA1 Message Date
manoraga c3466d09fc 13.08.2026 20:25 обновленная база данных 2026-08-13 20:23:47 +03:00
manoraga d2dd8c8907 13.08.2026 Рботает штатно. Только ошибка при открытии эксель файлов. 2026-08-13 14:20:52 +03:00
manoraga 8897b14197 12.08.2026 17:30 Логика формирования отчетов работает, все отсутствия учитываются, логика присутствия сотрудников в случае сбоя СКУД на работе настроена. 2026-08-12 17:33:48 +03:00
manoraga ffb0e18635 поправлено взаимодействие функционала работы с db_cli.py через диалоговую строку. 2026-08-11 16:20:15 +03:00
manoraga 339ea24243 работа со списком промптов и сессиями, которые запоминает ИИ. Нужно доработать. 2026-08-09 22:08:06 +03:00
manoraga fffd0f6d31 ничего не менялось. Только содержимое базы данных. 2026-08-09 13:34:43 +03:00
manoraga 24a8886e61 удалени лишнего после аварии, работа за рабочий день 07.08.2026 2026-08-07 17:05:28 +03:00
manoraga f6ca8552f6 восстановлена работа после потери main.py из-за неудачного слияния основного проекта и веб морды. 2026-08-07 07:57:20 +03:00
manoraga 9deca9ab34 перед внедрением веб интерфейса и API для взаимодействием с gemini. 2026-08-05 23:02:35 +03:00
manoraga 8f611ec41a готовность к созданию API и базы контекста для ИИ. 2026-08-05 16:29:17 +03:00
manoraga 0fd145df88 промежуточный этап. Работает. 2026-08-04 22:54:00 +03:00
manoraga 4ee7bd3a3d Перед переделкой, с учетом пробемы сбоя скуд и аномалиями входа сотрудников. ИИ должен фиксироать массовые сбои работы проекта. 2026-08-04 21:59:56 +03:00
manoraga 287b85fe8a разбор инцидента со сбоем базы скуд. 2026-08-04 21:10:52 +03:00
manoraga 2bdc07951a refactor: modular structure, core and services directories 2026-08-04 19:09:31 +03:00
manoraga 66e645cbd8 первая версия новой ветки 2026-08-04 18:56:00 +03:00
manoraga 947c494d8f очистка перед API 2026-08-04 18:29:25 +03:00
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
52 changed files with 7498 additions and 888 deletions
+40 -7
View File
@@ -1,9 +1,42 @@
# Входные и сгенерированные файлы
data/*.xlsx
output/*
# Системные и временные файлы Python
# --- 1. Python и виртуальное окружение ---
__pycache__/
*.pyc
*.py[cod]
*$py.class
venv/
.venv/
.vscode/
env/
.env
# --- 2. Настройки IDE и редакторов ---
.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
@@ -0,0 +1,59 @@
# 🛠 HOWTO: Помощь в технической диагностике системы (`scud_orion_ai_v2`)
Данный документ содержит руководство по работе со служебными диагностическими утилитами проекта. Скрипты предназначены для быстрой оценки состояния базы данных SQLite, анализа файловой структуры и формирования контекстного снимка кодовой базы для ИИ.
---
## 📋 Содержимое папки `scripts/diagnostics/`
Рекомендуется размещать диагностические скрипты в отдельной директории `scripts/diagnostics/`:
1. **`inspect_db.py`** — Инспектор таблиц и записей базы данных SQLite.
2. **`inspect_files.py`** — Сканер дискового пространства и структуры файлов.
3. **`make_code_snapshot.py`** — Генератор единого текстового слепка кода для ИИ.
---
## 🚀 Назначение и запуск утилит
### 1. Инспектор базы данных SQLite (`inspect_db.py`)
* **Назначение:**
* Проверяет доступность базы данных (`data/scud_orion_ai.db`).
* Автоматически считывает список всех таблиц и их колонок.
* Подсчитывает количество записей в каждой таблице (`scud_logs`, `zup_staff`, `zup_absences` и др.).
* Выводит список всех активных правил из таблицы `ai_knowledge_base`.
* **Команда для запуска:**
```bash
python scripts/diagnostics/inspect_db.py
2. Сканер файловой структуры (inspect_files.py)
Назначение:
Обходит структуру каталогов проекта.
Показывает точный размер каждого файла в байтах и итоговый вес проекта в мегабайтах.
Автоматически игнорирует служебные и временные папки (.git, __pycache__, venv, .venv).
Команда для запуска:
Bash
python scripts/diagnostics/inspect_files.py
3. Генератор контекстного снимка кода (make_code_snapshot.py)
Назначение:
Собирает весь исходный код Python (.py), настройки (.json), инструкции (.md) и скрипты (.sh) в единый файл project_code_snapshot.md.
Исключает тяжелые архивы, логи и виртуальное окружение.
Сформированный файл передается нейросети (LLM) для быстрого погружения в контекст проекта без потери архитектурных деталей.
Команда для запуска:
Bash
python scripts/diagnostics/make_code_snapshot.py
🎯 Чек-лист применения при сбоях
Ошибка или несостыковка в отчетах: запустить inspect_db.py и проверить, заполнены ли таблицы scud_logs и zup_absences.
Передача задачи или кода ИИ-ассистенту: запустить make_code_snapshot.py и прикрепить итоговый project_code_snapshot.md к запросу.
+73
View File
@@ -0,0 +1,73 @@
# 🛠 HOWTO: Правила внесения изменений и передачи кода в проекте
## 📌 Зачем это нужно
Проект `scud_orion_ai_v2` активно растет (17+ модулей, СУБД SQLite, ИИ-контур). Передача полных файлов кода при каждой мелкой правке сжигает окно контекста LLM, замедляет диалог и приводит к потере ранее внесенных архитектурных фич.
---
## 🎯 Правила для разработчика и ИИ
### 1. Формат передачи изменений
При запросах и ответах используются **точечные фрагменты кода** (Diff-стиль) с указанием привязок.
Любой фрагмент должен содержать:
1. **Точный путь к файлу** (`services/excel_exporter.py`).
2. **Имя целевой функции/класса** (`generate_summary_excel`).
3. **Зацепку (Context Anchor)** — уникальную соседнюю строку или блок `try/except`.
---
## 📐 Шаблоны фрагментов
### Вариант А: Замена существующей логики
> **Файл:** `services/excel_exporter.py`
> **Функция:** `generate_summary_excel()`
> **Место:** Строка ~150, блок фильтрации `no_pass_df`
>
> ❌ **Удалить:**
> ```python
> no_pass_df = merged_df[merged_df.get('no_scud_pass', False) == True]
> ```
>
> ✅ **Вставить:**
> ```python
> if 'no_scud_pass' in merged_df.columns:
> no_pass_df = merged_df[merged_df['no_scud_pass'] == True]
> else:
> no_pass_df = pd.DataFrame()
> ```
---
### Вариант Б: Вставка нового условия/блока
> **Файл:** `main.py`
> **Функция:** `detect_all_anomalies()`
> **Место:** После цикла `for idx, row in merged_df.iterrows():`
>
> ➕ **Вставить код:**
> ```python
> # ИСКЛЮЧЕНИЯ без документов 1С НЕ пропускаются дальше к аномалиям СКУД
> if is_exc and not has_1c_reason:
> continue
> ```
---
### Вариант В: Git Unified Diff Format
> **Файл:** `services/data_loader.py`
> ```diff
> @@ def process_scud_anomalies(df_scud, exceptions=None): @@
> - first_act = '—'
> + # Сохраняем вычисленную ранее активность из БД/файла
> + first_act = existing_fa if existing_fa not in ['—', '', 'None'] else '—'
> ```
---
## 🚀 Чек-лист перед запуском
1. Проверить синтаксис через `python -m py_compile <файл.py>`.
2. Запустить скрипт с флагом отладки `python main.py -d`.
3. Убедиться, что снапшот записан в SQLite (`python scripts/db_cli.py snapshots`).
-133
View File
@@ -1,133 +0,0 @@
# Архитектура и документация проекта СКУД 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-файлов СКУД оператором.
+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С |
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
# 🗺 Реестр задач проекта: SCUD Orion AI (v2)
## 🟢 Выполненные задачи (Completed Tasks)
* **[TASK-01]** `services/data_loader` — Исправление KeyError по ФИО в Excel экспорте 1С (переход на `.iloc`) *(03.08.2026)* — **ГОТОВО**
* **[TASK-02]** `services/zup_extractor` — Перевод подключения 1С:ЗУП на продакшн MS SQL сервер `ACCOUNT-01` + гибридный фоллбэк *(04.08.2026)* — **ГОТОВО**
* **[TASK-03]** `services/scud_export` — Инцидент Равина В.Э.: Фиксация 100% приоритета сырых логов `pLogData` СКУД над расчетом УРВ Ориона *(04.08.2026)* — **ГОТОВО**
* **[TASK-04]** `core/database` — Реализация схемы SQLite WAL, сохранение снапшотов и маппинг колонок (`department`/`position`) *(05.08.2026)* — **ГОТОВО**
* **[TASK-05]** `core/database` — Исправление бага дублирования номеров снапшотов (переход на вычисление `MAX()` по суффиксу) *(05.08.2026)* — **ГОТОВО**
* **[TASK-06]** `scripts/fix_snapshots` — Создание и выполнение скрипта сквозной переиндексации снапшотов с префиксом `Y` *(05.08.2026)* — **ГОТОВО**
* **[TASK-07]** `scripts/db_cli` — Реализация CLI-инспектора БД и выравнивание номеров срезов для идеальной верстки в терминале *(05.08.2026)* — **ГОТОВО**
* **[TASK-08]** `main.py / logic` — Двойная проверка исключений по `department_scud` и 1С, удержание 100% приоритета кадровых документов *(05.08.2026)* — **ГОТОВО**
* **[TASK-09]** `services/text_reporter` — Лаконичный вывод неразмеченных сотрудников с короткими аббревиатурами отделов СКУД (`РУК`, `ОИЗ`, `ПУ` и др.) *(05.08.2026)* — **ГОТОВО**
---
## 🟡 Ближайшие планы и бэклог (In Progress & Backlog v2.1)
* **[TASK-10]** `services/scud_export` — **Внутридневной контроль:** Извлечение полных цепочек проходов `pLogData` (входы/выходы за смену) — **В ПЛАНАХ** [High]
* **[TASK-11]** `services/data_loader` — **Внутридневной контроль:** Расчет количества и суммарной длительности отлучек/перекуров сверх обеда — **В ПЛАНАХ** [High]
* **[TASK-12]** `services/text_reporter` — **Внутридневной контроль:** Вывод списка системных нарушителей внутреннего распорядка в Markdown-отчет — **В ПЛАНАХ** [Medium]
* **[TASK-13]** `automation` — Автоматизация сбора срезов по расписанию (Cron / Systemd Timers на **12:00**, **17:00**, **19:00**) — **В ПЛАНАХ** [High]
* **[TASK-14]** `services/notifications` — Интеграция Telegram-бота для алертов Администратору при массовых сбоях турникетов (>5%) — **В ПЛАНАХ** [Medium]
---
## 🔵 Дорожная карта Веб-релиза (Roadmap v3.0)
* **[TASK-15]** `api / fastapi` — Создание REST API на FastAPI (`/api/v1/snapshots`, `/api/v1/scud/logs`, `/api/v1/anomalies`) — **В ПЛАНАХ** [High]
* **[TASK-16]** `frontend / chat` — Веб-интерфейс с ИИ-чатом на базе Ollama (Qwen 2.5 14B) с поддержкой Function Calling к REST API — **В ПЛАНАХ** [High]
* **[TASK-17]** `core/memory` — Создание модуля персистентной памяти проекта в SQLite и CLI-инструмента для фиксации архитектурных решений — **В ПЛАНАХ** [High]
View File
+21
View File
@@ -0,0 +1,21 @@
import openpyxl
wb_good = openpyxl.load_workbook('output/5 августа 2026 отчет.xlsx') # или ваш путь к рабочему
wb_bad = openpyxl.load_workbook('output/10 августа 2026 отчет.xlsx')
ws_good = wb_good.active
ws_bad = wb_bad.active
print(f"Размер хорошего: {ws_good.max_row} строк, {ws_good.max_column} колонок")
print(f"Размер с ошибкой: {ws_bad.max_row} строк, {ws_bad.max_column} колонок")
# Сравним типы ячеек в колонках
for r in range(4, min(ws_good.max_row, ws_bad.max_row) + 1):
for c in range(1, 11):
v_g = ws_good.cell(row=r, column=c).value
v_b = ws_bad.cell(row=r, column=c).value
t_g = type(v_g)
t_b = type(v_b)
if t_g != t_b:
print(f"Строка {r}, колонка {c}: тип в хорошем {t_g}, в плохом {t_b}")
+64 -57
View File
@@ -1,78 +1,85 @@
import os
import re
import json
import pandas as pd
from datetime import datetime, timedelta
# 📁 Пути к директориям
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
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")
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат)
SHARE_1C_DIR = "/mnt/scud_share"
# 📅 Динамический расчет дат (СЕГОДНЯ и ВЧЕРА)
def get_controlling_dates():
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")
date_yesterday_str = yesterday_dt.strftime("%d.%m.%Y")
return date_today_str, date_yesterday_str
for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]:
os.makedirs(folder, exist_ok=True)
DATE_TODAY, DATE_YESTERDAY = get_controlling_dates()
NOW = datetime.now()
DATE_TODAY = NOW.strftime("%d.%m.%Y")
# 📄 Пути к файлам
SCUD_FILE_TODAY = os.path.join(DATA_DIR, f"Сотрудники_{DATE_TODAY}.xlsx")
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
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")
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"
# --- Настройки LLM / Ollama ---
OLLAMA_URL = "http://192.168.11.3:11434/api/generate"
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):
"""Строгая нормализация ФИО для точности сопоставления 1С и СКУД"""
if pd.isna(val):
for d in search_dirs:
if not os.path.exists(d):
continue
for f in os.listdir(d):
if f.endswith('.xlsx') or f.endswith('.csv'):
if f.lower().startswith(prefix.lower()):
if date_dots in f or date_underscores in f:
return os.path.join(d, f)
return None
def normalize_fio(fio):
if not fio or not isinstance(fio, str):
return ""
val = str(val).strip().lower()
val = re.sub(r'\(.*?\)', '', val)
trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ')
val = val.translate(trans).replace('ё', 'е')
return re.sub(r'\s+', ' ', val).strip()
fio_clean = re.sub(r'\(.*?\)', '', fio)
fio_clean = fio_clean.replace('\xa0', ' ')
parts = fio_clean.strip().split()
return " ".join(parts).title()
def clean_scud_fio_light(val):
"""Легкая очистка ФИО для СКУД"""
if pd.isna(val):
return ""
val = str(val).strip().lower()
val = re.sub(r'\(.*?\)', '', val).replace('ё', 'е')
return re.sub(r'\s+', ' ', val).strip()
def clean_scud_fio_light(fio_str):
return normalize_fio(fio_str)
def load_exceptions(file_path=EXCEPTIONS_FILE):
"""Загрузка списка исключений из JSON"""
if not os.path.exists(file_path):
return {"departments": [], "positions": [], "fio": [], "position_keywords": []}
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
return {
"departments": [str(d).strip().upper() for d in data.get("departments", [])],
"positions": [str(p).strip().lower() for p in data.get("positions", [])],
"fio": [normalize_fio(f) for f in data.get("fio", [])],
"position_keywords": [str(k).strip().lower() for k in data.get("position_keywords", [])]
}
# тестовый комментарий
def load_exceptions():
import json
if os.path.exists(EXCEPTIONS_PATH):
try:
with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {"fio": [], "departments": [], "positions": [], "position_keywords": []}
BIN
View File
Binary file not shown.
View File
+480
View File
@@ -0,0 +1,480 @@
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()
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,
first_activity TEXT DEFAULT '—',
time_out TEXT,
time_in_building TEXT,
is_present INTEGER NOT NULL,
anomaly_flag TEXT DEFAULT 'NONE',
snapshot_time TEXT DEFAULT NULL,
snapshot_id TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("PRAGMA table_info(scud_logs);")
cols = [col[1] for col in cursor.fetchall()]
if 'snapshot_id' not in cols:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;")
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
);
""")
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
);
""")
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
);
""")
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_scud_snapshot ON scud_logs(snapshot_time);")
conn.commit()
sync_knowledge_base_to_db()
def has_scud_logs_for_date(date_str):
"""Проверяет, есть ли в базе данные СКУД за указанную дату."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
return cursor.fetchone() is not None
def has_yesterday_final_snapshot(date_str):
"""Проверяет, зафиксирован ли уже ИТОГОВЫЙ вчерашний снапшот с индексом Y/22:00."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
(date_str,)
)
return cursor.fetchone() is not None
def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False):
"""
Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN).
Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time).
"""
try:
dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError):
dt_snap = datetime.now().date()
date_prefix = dt_snap.strftime("%Y%m%d")
# Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y
if date_str:
try:
dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
if dt_log < dt_snap:
is_yesterday = True
except Exception:
pass
prefix = "Y" if is_yesterday else ""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(date_str, snapshot_time)
)
else:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(snapshot_time,)
)
row = cursor.fetchone()
if row and row[0]:
return row[0]
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
ORDER BY snapshot_id DESC LIMIT 1
""", (f"{date_prefix}-%", f"Y{date_prefix}-%"))
last_row = cursor.fetchone()
next_seq = 1
if last_row and last_row[0]:
parts = last_row[0].replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
next_seq = int(parts[1]) + 1
return f"{prefix}{date_prefix}-{next_seq:03d}"
def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False):
"""Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id."""
if df_scud is None or df_scud.empty:
return
if not snapshot_time:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Передаем date_str в генератор snapshot_id
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
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('Конец_дня', 'Нет выхода'),
r.get('Находился_в_здании', '00:00'),
1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'),
snapshot_time,
snapshot_id
))
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
cursor.executemany("""
INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time, snapshot_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def get_latest_snapshot_time(date_str=None):
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
else:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
row = cursor.fetchone()
return row[0] if row else None
def load_scud_from_db_by_snapshot(date_str, snapshot_param=None):
"""
Загружает логи СКУД по ID снапшота.
Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает
финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str.
"""
with get_connection() as conn:
df = pd.DataFrame()
# 1. Если передан конкретный ID снапшота (например, '20260810-001')
if snapshot_param:
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(str(snapshot_param),)
)
# 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день):
if df.empty and date_str:
cursor = conn.cursor()
# ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот)
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
# ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный
if not row:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
if row and row[0]:
target_snap_id = row[0]
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(target_snap_id,)
)
# Приводим названия колонок к стандарту
if not df.empty:
rename_map = {
'department': 'Подразделение',
'position': 'Должность',
'fio': 'Сотрудник',
'time_in': 'Начало_дня',
'first_activity': 'Первая_активность',
'time_out': 'Конец_дня',
'duration': 'Находился_в_здании',
'is_present': 'Пришел'
}
# Переименовываем имеющиеся колонки
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
# Заполняем недостающие колонки значениями по умолчанию, если их не было в БД
required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']
for col in required_cols:
if col not in df.columns:
if col == 'Пришел':
df[col] = False
else:
df[col] = '—'
# Приводим тип флага Пришел к bool
if 'Пришел' in df.columns:
df['Пришел'] = df['Пришел'].astype(bool)
return df
def get_available_snapshots(date_str=None):
"""
Возвращает список снапшотов.
Сортировка по времени snapshot_time DESC.
"""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""", (date_str,))
else:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""")
return cursor.fetchall()
def save_staff_to_db(df_staff, date_str):
if df_staff is None or df_staff.empty:
return
data_to_insert = [
(date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
for _, r in df_staff.iterrows()
]
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):
if df_absent is None or df_absent.empty:
return
data_to_insert = [
(date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
for _, r in df_absent.iterrows()
]
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")
return [r[0] for r in cursor.fetchall()]
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}")
def sync_knowledge_base_to_db():
pass
def delete_snapshot_by_id(snapshot_id: str):
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}")
return deleted_count
def delete_snapshots_by_date(date_str: str):
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
with get_connection() as conn:
cursor = conn.cursor()
# Удаляем по log_date или по дате внутри snapshot_id
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}")
return deleted_count
def init_department_synonyms_db():
"""Создает таблицу синонимов отделов в БД SQLite."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS department_synonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_name TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# По умолчанию добавляем ОВК -> Отдел внутреннего контроля
cursor.execute("""
INSERT OR IGNORE INTO department_synonyms (short_name, full_name)
VALUES ('овк', 'отдел внутреннего контроля')
""")
conn.commit()
def get_department_synonyms_dict():
"""Возвращает словарь всех изученных синонимов {short_name: full_name}."""
init_department_synonyms_db()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
return {row[0]: row[1] for row in cursor.fetchall()}
def add_department_synonym_to_db(short_name, full_name):
"""Сохраняет новую пару синонимов отдела в базу SQLite."""
init_department_synonyms_db()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)",
(short_name.strip().lower(), full_name.strip().lower())
)
conn.commit()
print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'")
def load_staff_from_db(date_str):
"""Загружает Штатное расписание 1С из базы SQLite за указанную дату."""
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
def load_absences_from_db(date_str):
"""Загружает документальные отсутствия 1С из базы SQLite за указанную дату."""
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
-9
View File
@@ -1,9 +0,0 @@
{
"rules": [
"Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.",
"Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями."
],
"fio_corrections": {
"кондрашава елена владимировна": "Кондрашова Елена Владимировна"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -7,4 +7,8 @@ fio,reason,note
Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка
Ароян Станислав Месропович,Удаленная работа,Временная удаленка
1 fio reason note
7 Шуличенко Иван Иванович Удаленная работа Постоянная удаленка
8 Пухаренко Юрий Владимирович Удаленная работа Постоянная удаленка
9 Пшеничный Виктор Петрович Удаленная работа Постоянная удаленка
10 Незнанова Валерия Игоревна Удаленная работа Постоянная удаленка
11 Ковалев Владимир Владимирович Удаленная работа Постоянная удаленка
12 Кожокарь Татьяна Юрьевна Удаленная работа Постоянная удаленка
13 Субетто Юлия Викторовна Удаленная работа Постоянная удаленка
14 Ароян Станислав Месропович Удаленная работа Временная удаленка
+496 -127
View File
@@ -1,25 +1,109 @@
import os
import sys
import json
import argparse
import pandas as pd
from datetime import datetime, timedelta
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio
from modules.data_validator import check_file_freshness
from modules.data_loader import load_all_data
from modules.excel_exporter import generate_summary_excel, generate_detailed_excel
from modules.ai_verifier import ai_verify_scud_against_staff
from modules.text_reporter import generate_markdown_report
from modules.feedback_loop import review_ai_decisions
from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share
from services.data_validator import check_file_freshness
from services.data_loader import load_1c_data_smart, load_scud_data
from services.excel_exporter import generate_summary_excel, generate_detailed_excel
from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai
from services.text_reporter import generate_markdown_report
from services.feedback_loop import review_ai_decisions
from services.knowledge_base import load_knowledge_base
from core.database import (
init_db,
save_scud_to_db,
save_staff_to_db,
save_absences_to_db,
save_anomalies_to_db,
load_scud_from_db_by_snapshot,
load_staff_from_db,
load_absences_from_db,
get_latest_snapshot_time,
has_scud_logs_for_date
)
def load_exceptions_config():
"""Загружает файл exceptions.json из корня проекта."""
root_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(root_dir, "exceptions.json")
if not os.path.exists(json_path):
return {}
try:
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[⚠️] Ошибка чтения exceptions.json: {e}")
return {}
def apply_exceptions_from_json(df, exceptions_cfg):
"""
Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json.
Выполняется мгновенно без цикличных HTTP-запросов к ИИ.
"""
if df is None or df.empty or not exceptions_cfg:
if df is not None:
df['is_excluded'] = False
return df
deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d]
exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
df['is_excluded'] = False
for idx, row in df.iterrows():
fio_clean = row.get('fio_clean', '')
dep_1c = str(row.get('Подразделение', '')).lower()
dep_scud = str(row.get('department_scud', row.get('department', ''))).lower()
pos = str(row.get('Должность', '')).lower()
is_fio_exc = fio_clean in exc_fios
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False
# Быстрая проверка отделов по подстрокам
is_dep_exc = False
if deps:
is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps)
if is_fio_exc or is_dep_exc or is_pos_exc:
df.at[idx, 'is_excluded'] = True
return df
def filter_report_dataframe(merged_df):
"""
Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета,
ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП.
"""
if merged_df is None or merged_df.empty:
return merged_df
has_1c_reason = (
merged_df['Вид_отсутствия'].notna() &
(merged_df['Вид_отсутствия'].astype(str).str.strip() != '') &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
)
is_not_excluded = merged_df.get('is_excluded', False) == False
is_not_no_pass = merged_df.get('no_scud_pass', False) == False
filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy()
return filtered_df
def load_static_reason_workers():
"""
Загружает реестр сотрудников со статическими/особыми причинами
из static_reason_workers.csv.
Возвращает словарь: {fio_clean: reason}
"""
"""Загружает реестр удалёнщиков и статических причин из CSV."""
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if not os.path.exists(static_path):
return {}
@@ -33,13 +117,62 @@ def load_static_reason_workers():
return {}
def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None):
"""
Автоматически выявляет истинные аномалии СКУД ⟷ 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)
is_exc = row.get('is_excluded', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
anom_flag = row.get('anomaly_flag', 'NONE')
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}'"
})
if is_exc and not has_1c_reason:
continue
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
anomalies.append({
"type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
"fio": fio,
"details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
})
if scud_fios_set is not None:
if fio_clean not in scud_fios_set and not has_1c_reason:
anomalies.append({
"type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
"fio": fio,
"details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)"
})
return anomalies
def aggregate_scud_by_employee(df, debug=False):
"""
Агрегирует записи СКУД по сотруднику (fio_clean).
Устраняет дубликаты карт/табельных номеров (например, Кондрашова Е.В.):
Если человек пришел хотя бы по одной карте — Пришел = True.
"""
if df.empty or 'fio_clean' not in df.columns:
"""Агрегирует проходы СКУД по уникальным сотрудникам."""
if df is None or df.empty or 'fio_clean' not in df.columns:
return df
aggregated = []
@@ -53,150 +186,386 @@ def aggregate_scud_by_employee(df, debug=False):
best_row = group.iloc[0].to_dict()
best_row['Пришел'] = is_present
if debug and len(group) > 1:
print(f" [DEBUG] Найдено дубликатов карт/записей для {fio_clean}: {len(group)} шт. Статус присутствия: {is_present}")
aggregated.append(best_row)
return pd.DataFrame(aggregated)
def main():
parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С")
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки (подробный вывод)")
args = parser.parse_args()
help_text = """
Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2)
ПРИМЕРЫ ЗАПУСКА:
python main.py -- Обычный дневной запуск
python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite
python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота
python main.py -d -- Запуск в режиме расширенной отладки (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов")
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту")
parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания")
args = parser.parse_args()
DEBUG = args.debug
print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
print("=" * 60)
# 0. Валидация файлов
print("[0/5] Проверка актуальности и свежести входных файлов...")
is_valid, warnings, errors = check_file_freshness()
init_db()
kb_data = load_knowledge_base()
kb_rules = kb_data.get("rules", [])
exceptions_cfg = load_exceptions_config()
if warnings:
print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---")
for w in warnings:
print(f" {w}")
print("-" * 45)
if args.snapshot:
snapshot_param = args.snapshot
print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'")
elif args.skip_export:
snapshot_param = get_latest_snapshot_time()
print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')")
else:
snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
if not is_valid:
print("\n" + "!" * 60)
print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Обнаружены критические ошибки данных!")
for e in errors:
print(f" {e}")
print("!" * 60)
sys.exit(1)
if not args.skip_export and not args.snapshot:
try:
run_export(save_xlsx=True, debug=DEBUG)
except Exception as e:
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
else:
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
print("[✓] Проверка актуальности успешно пройдена!\n")
# ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА
if not args.snapshot and not args.skip_export:
copy_1c_files_from_share()
# 1. Загрузка данных
print("[1/5] Загрузка данных СКУД, 1С, реестра статических причин и исключений...")
raw_scud_df, staff_fios_clean, df_absent = load_all_data()
print("[1/5] Проверка актуальности и свежести входных данных...")
is_valid, warnings, errors, has_today_1c = check_file_freshness()
if warnings:
print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---")
for w in warnings:
print(f" • {w}")
print("-" * 45)
if not is_valid:
print("\n" + "!" * 60)
print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!")
for e in errors:
print(f" {e}")
print("!" * 60)
sys.exit(1)
print("[✓] Проверка доступности данных успешно пройдена!\n")
else:
print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...")
has_today_1c = True
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
# 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ
if args.snapshot or args.skip_export:
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param)
if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns:
target_date_str = str(raw_scud_today_df['log_date'].iloc[0])
else:
target_date_str = DATE_TODAY
dt_target = datetime.strptime(target_date_str, "%d.%m.%Y")
if dt_target.weekday() == 0: # Понедельник -> Пятница
dt_yesterday = dt_target - timedelta(days=3)
else:
dt_yesterday = dt_target - timedelta(days=1)
yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y")
print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}")
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
# Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк)
df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True)
else:
target_date_str = DATE_TODAY
yesterday_date_str = DATE_YESTERDAY
raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param)
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False)
if df_staff_yesterday is not None:
save_staff_to_db(df_staff_yesterday, yesterday_date_str)
if df_absent_yesterday is not None:
save_absences_to_db(df_absent_yesterday, yesterday_date_str)
if df_staff_today is not None:
save_staff_to_db(df_staff_today, target_date_str)
if df_absent_today is not None:
save_absences_to_db(df_absent_today, target_date_str)
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()
if DEBUG:
print(f" [DEBUG] Загружено записей СКУД: {len(raw_scud_df)}")
print(f" [DEBUG] Загружено сотрудников из 1С: {len(staff_fios_clean)}")
print(f" [DEBUG] Загружено докум. отсутствий 1С: {len(df_absent)}")
print(f" [DEBUG] Загружено статических причин: {len(static_reasons_dict)}")
# 2. ИИ-сверка ФИО
print("[2/5] Проверка опечаток и сопоставление ФИО через ИИ...")
scud_unrecognized = raw_scud_df[~raw_scud_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist()
fio_mapping = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean)
# ============================================================
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ)
# ============================================================
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
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
if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False
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 []
fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
if fio_mapping_scud_y:
raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
)
# 2.1 Агрегация нескольких карт/дубликатов
raw_scud_df = aggregate_scud_by_employee(raw_scud_df, debug=DEBUG)
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
# 3. Объединение данных и иерархия приоритетов
print("[3/5] Объединение реестров 1С, СКУД, статических причин и исключений...")
merged = raw_scud_df[raw_scud_df['fio_clean'].isin(staff_fios_clean)].copy()
# 🎯 ПРИОРИТЕТ 1: Выгрузка докум. отсутствий 1С:ЗУП (Отсутствия сотрудников - выгрузка.xlsx)
merged = merged.merge(df_absent, on='fio_clean', how='left')
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
# ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ
merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame())
if not merged_yesterday.empty:
if not raw_scud_yesterday_df.empty:
if 'Подразделение' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
elif 'department' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
else:
raw_scud_yesterday_df['department_scud'] = ''
merged_yesterday = merged_yesterday.merge(
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if 'Вид_отсутствия' in merged_yesterday.columns:
merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия'])
# Присоединяем отсутствия СТРОГО за дату yesterday_date_str
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
merged_yesterday = merged_yesterday.merge(
df_absent_yesterday[['fio_clean', 'Вид_отсутствия']],
on='fio_clean',
how='left'
)
if 'Пришел' not in merged_yesterday.columns:
merged_yesterday['Пришел'] = False
else:
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
if 'Сотрудник' not in merged_yesterday.columns:
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
# 🎯 ПРИОРИТЕТ 2: Статические причины из static_reason_workers.csv
# Применяются ТОЛЬКО если в 1С:ЗУП документа нет и сотрудник отсутствует в СКУД
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_apply_static = (
(merged['Пришел'] == False) &
(merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged['fio_clean'] == fio_clean)
mask_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
# 🎯 ПРИОРИТЕТ 3: Исключения по должности/департаменту/ФИО (exceptions.json)
# Если сотрудник попал в исключения (is_excluded = True), НЕ пришел в СКУД и НЕ имеет документа в 1С:
# Присваиваем ему статус "Исключение (ОВК/Подрядчики)", чтобы он НЕ ПОПАДАЛ в неизвестные!
mask_apply_exceptions = (
(merged['Пришел'] == False) &
(merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged.get('is_excluded', False) == True)
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
mask_exc_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == True)
)
merged.loc[mask_apply_exceptions, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
absent_explained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].notna())]
absent_unexplained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna())]
scud_present_but_absent_in_1c = merged[(merged['Пришел'] == True) & (merged['Вид_отсутствия'].notna())]
if DEBUG:
print("\n--- [DEBUG STATS] ---")
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] Запуск ИИ-аудитора и проверка аномалий...")
report_text = generate_markdown_report(
merged_df=merged,
absent_explained=absent_explained,
absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
raw_scud_df=raw_scud_df,
date_str=DATE_TODAY
scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set()
merged_yesterday['no_scud_pass'] = (
(~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == False)
)
suspicious_cases = []
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)
anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set)
save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str)
# 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)
filtered_yesterday = filter_report_dataframe(merged_yesterday)
generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str)
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ:")
print("=" * 60)
print(report_text)
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{DATE_TODAY}.md")
with open(md_report_path, "w", encoding="utf-8") as f:
f.write(report_text)
print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}")
yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y")
yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00")
save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True)
print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}")
# ============================================================
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА)
# ============================================================
if not args.snapshot and not args.skip_export:
save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False)
if has_today_1c and df_staff_today is not None and df_absent_today is not None:
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...")
staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns:
raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False
scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else []
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 = df_staff_today.copy()
if not raw_scud_today_df.empty:
if 'Подразделение' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение']
elif 'department' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['department']
else:
raw_scud_today_df['department_scud'] = ''
merged_today = merged_today.merge(
raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if df_absent_today is not None and not df_absent_today.empty:
merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
if 'Пришел' not in merged_today.columns:
merged_today['Пришел'] = False
else:
merged_today['Пришел'] = merged_today['Пришел'].fillna(False)
if 'Сотрудник' not in merged_today.columns:
merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean'])
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
merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg)
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, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
merged_today['no_scud_pass'] = (
(~merged_today['fio_clean'].isin(scud_fios_today_set)) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == False)
)
scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set)
mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df)
if mass_failure_today and mass_failure_today.get("is_mass_failure"):
print("\n" + "!" * 60)
print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)")
print(mass_failure_today["alert_text"])
print("!" * 60 + "\n")
save_anomalies_to_db(anomalies_list, target_date_str)
is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False
is_exc_today = merged_today.get('is_excluded', False) == True
absent_explained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_unexplained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass_today) &
(~is_exc_today)
]
scud_present_but_absent_in_1c = merged_today[
(~is_exc_today) &
(merged_today['Пришел'] == True) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
filtered_anomalies_list = [
a for a in anomalies_list
if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
]
report_text = generate_markdown_report(
merged_df=merged_today,
absent_explained=absent_explained,
absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
anomalies_list=filtered_anomalies_list,
raw_scud_df=raw_scud_today_df,
raw_absent_df=df_absent_today,
date_str=target_date_str
)
generate_summary_excel(merged_df=merged_today, date_str=target_date_str)
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md")
with open(md_report_path, "w", encoding="utf-8") as f:
f.write(report_text)
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[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
if __name__ == "__main__":
-77
View File
@@ -1,77 +0,0 @@
import json
import re
import requests
from config import OLLAMA_URL, OLLAMA_MODEL
def ask_ollama(prompt, model=OLLAMA_MODEL, system_prompt=None):
"""Базовая функция отправки запросов к Ollama"""
if system_prompt is None:
system_prompt = "Ты — офисный аудитор безопасности и кадрового учета."
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
"stream": False
}
try:
response = requests.post(OLLAMA_URL, json=payload, timeout=60)
if response.status_code == 200:
return response.json()['message']['content']
else:
return f"Ошибка API Ollama: {response.status_code}"
except Exception as e:
return f"Ошибка подключения к Ollama: {e}"
def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
"""ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП"""
if not unrecognized_scud_fios or not staff_fios:
return {}
prompt = f"""
Ты — кадровый аудитор безопасности СКУД.
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
СТРОГИЕ ПРАВИЛА:
1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон.
2. В СКУД допущена опечатка.
3. В поле "warning" описать опечатку.
4. Запрещено выдумывать опечатки!
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
Формат ответа:
{{
"verified_matches": [
{{
"scud_fio": "ФИО из СКУД",
"staff_fio": "эталон ФИО из Штат 1С",
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
}}
]
}}
"""
raw_response = ask_ollama(
prompt,
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
)
mapping = {}
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
json_str = match.group(0)
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
data = json.loads(json_str)
for item in data.get("verified_matches", []):
scud_f = item.get("scud_fio")
staff_f = item.get("staff_fio")
warn = item.get("warning", "Точное совпадение (без опечаток)")
if scud_f and staff_f:
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
except Exception as e:
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
return mapping
-78
View File
@@ -1,78 +0,0 @@
import os
import pandas as pd
import warnings
from config import (
normalize_fio, clean_scud_fio_light, load_exceptions,
DATA_DIR, SCUD_FILE, SCUD_FILE_TODAY, SCUD_FILE_YESTERDAY
)
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
def is_excluded(fio, dept, pos, exceptions):
fio_clean = normalize_fio(fio)
dept_str = str(dept).strip().upper() if pd.notna(dept) else ""
pos_str = str(pos).strip().lower() if pd.notna(pos) else ""
if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]:
return True
if any(keyword in pos_str for keyword in exceptions["position_keywords"]):
return True
return False
def load_staff_data():
staff_path = os.path.join(DATA_DIR, "Штатные сотрудники - ОУП.xlsx")
if not os.path.exists(staff_path):
raise FileNotFoundError(f"Файл штатного расписания не найден: {staff_path}")
df_staff = pd.read_excel(staff_path, skiprows=7)
df_staff = df_staff.rename(columns={df_staff.columns[1]: 'ФИО'})
df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio)
return df_staff
def load_absent_data():
absent_path = os.path.join(DATA_DIR, "Отсутствия сотрудников - выгрузка.xlsx")
if not os.path.exists(absent_path):
raise FileNotFoundError(f"Файл отсутствий не найден: {absent_path}")
df_absent_raw = pd.read_excel(absent_path, skiprows=3)
df_absent = df_absent_raw[1:].copy().reset_index(drop=True)
df_absent = df_absent.rename(columns={df_absent.columns[0]: 'ФИО', df_absent.columns[2]: 'Вид_отсутствия'})
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
return df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
def load_scud_data():
scud_path = SCUD_FILE_TODAY if os.path.exists(SCUD_FILE_TODAY) else (SCUD_FILE_YESTERDAY if os.path.exists(SCUD_FILE_YESTERDAY) else SCUD_FILE)
if not os.path.exists(scud_path):
raise FileNotFoundError(f"Файл СКУД не найден в папке {DATA_DIR}")
df_scud = pd.read_excel(scud_path)
exceptions = load_exceptions()
df_scud['fio_raw'] = df_scud['Сотрудник'].astype(str) if 'Сотрудник' in df_scud.columns else df_scud[df_scud.columns[0]].astype(str)
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
status_col = 'Статус' if 'Статус' in df_scud.columns else None
# 🎯 ФИЗИЧЕСКИЙ ФАКТ ПРИСУТСТВИЯ
def is_person_present(row):
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
val_time = str(row.get(start_col, '')).strip() if start_col else ''
if 'отсутствовал' in val_status or val_time in ['нет входа', 'none', 'nan', '', '00:00']:
return False
return True
df_scud['Пришел'] = df_scud.apply(is_person_present, axis=1)
# Отмечаем флаг исключений, но НЕ удаляем сразу
df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions),
axis=1
)
return df_scud
def load_all_data():
df_scud = load_scud_data()
df_staff = load_staff_data()
df_absent = load_absent_data()
staff_fios_clean = df_staff['fio_clean'].dropna().tolist()
return df_scud, staff_fios_clean, df_absent
-46
View File
@@ -1,46 +0,0 @@
import os
import pandas as pd
from datetime import datetime, timedelta
from config import DATA_DIR, DATE_TODAY, DATE_YESTERDAY
def check_file_freshness():
"""
Проверяет наличие и актуальность файлов СКУД и 1С.
Возвращает (is_valid, warnings_list, errors_list)
"""
errors = []
warnings = []
# Ожидаемые имена файлов
scud_today_file = os.path.join(DATA_DIR, f"Сотрудники_{DATE_TODAY}.xlsx")
scud_yesterday_file = os.path.join(DATA_DIR, f"Сотрудники_{DATE_YESTERDAY}.xlsx")
staff_file = os.path.join(DATA_DIR, "Штатные сотрудники - ОУП.xlsx")
absent_file = os.path.join(DATA_DIR, "Отсутствия сотрудников - выгрузка.xlsx")
now = datetime.now()
# 1. Проверка файла СКУД за СЕГОДНЯ
if not os.path.exists(scud_today_file):
errors.append(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Отсутствует актуальный файл СКУД за сегодня: 'Сотрудники_{DATE_TODAY}.xlsx'!")
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 os.path.exists(scud_yesterday_file):
warnings.append(f"⚠️ ПРЕДУПРЕЖДЕНИЕ: Файл СКУД за вчера ({DATE_YESTERDAY}) не найден. Детальный отчет за вчера может быть неполным.")
# 3. Проверка файлов 1С (Штат и Отсутствия)
for f_path, f_name in [(staff_file, "Штатные сотрудники"), (absent_file, "Выгрузка отсутствий")]:
if not os.path.exists(f_path):
errors.append(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Файл 1С '{f_name}' не найден в папке data/!")
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
return is_valid, warnings, errors
-33
View File
@@ -1,33 +0,0 @@
from modules.knowledge_base import add_rule_to_kb
def review_ai_decisions(ai_report, suspicious_cases):
"""
Интерактивный модуль: показывает спорные моменты человеку
и записывает его вердикт в память ИИ.
"""
print("\n" + "="*60)
print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)")
print("="*60)
if not suspicious_cases:
print("Спорных случаев для обучения сегодня не обнаружено.")
return
for case in suspicious_cases:
fio = case.get('fio_target')
reason = case.get('reason')
print(f"\n[?] Спорный случай: {fio}")
print(f" Причина аномалии: {reason}")
user_input = input("Согласны с решением ИИ? (д - да / н - нет / введите новое правило): ").strip()
if user_input.lower() == 'н' or len(user_input) > 5:
if user_input.lower() == 'н':
rule_text = input(f"Опишите правило для {fio} (например: 'Считать совместителем'): ")
else:
rule_text = user_input
# Записываем правило в Базу Знаний
full_rule = f"Сотрудник {fio}: {rule_text}"
add_rule_to_kb(full_rule)
print(f"[+] ИИ 'запомнил' это правило и учитывает его со следующего запуска!")
-20
View File
@@ -1,20 +0,0 @@
import json
import os
KB_FILE = os.path.join("data", "knowledge_base.json")
def load_knowledge_base():
"""Загружает накопленный опыт и правила компании"""
if not os.path.exists(KB_FILE):
return {"rules": [], "fio_corrections": {}}
with open(KB_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def add_rule_to_kb(new_rule):
"""Добавляет новое правило, принятое человеком, в базу знаний"""
kb = load_knowledge_base()
if new_rule not in kb["rules"]:
kb["rules"].append(new_rule)
with open(KB_FILE, "w", encoding="utf-8") as f:
json.dump(kb, f, ensure_ascii=False, indent=2)
print(f"[✓] База знаний обновлена! Новое правило: {new_rule}")
-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] === Завершение работы скрипта ===
-91
View File
@@ -1,91 +0,0 @@
import json
from modules.ai_verifier import ask_ollama
from modules.knowledge_base import load_knowledge_base
def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, raw_scud_df=None, raw_staff_df=None, date_str="27.07.2026"):
kb = load_knowledge_base()
custom_rules = kb.get("rules", [])
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
detailed_cases_for_ai = []
unexplained_fios = absent_unexplained['fio_clean'].unique() if 'fio_clean' in absent_unexplained.columns else []
for fio_clean in unexplained_fios:
staff_matches = []
if raw_staff_df is not None and not raw_staff_df.empty and 'fio_clean' in raw_staff_df.columns:
st_m = raw_staff_df[raw_staff_df['fio_clean'] == fio_clean]
cols_staff = [c for c in ['ФИО', 'Подразделение', 'Должность', 'Таб_№'] if c in st_m.columns]
staff_matches = st_m[cols_staff].to_dict(orient='records')
scud_matches = []
if raw_scud_df is not None and not raw_scud_df.empty and 'fio_clean' in raw_scud_df.columns:
sc_m = raw_scud_df[raw_scud_df['fio_clean'] == fio_clean]
cols_scud = [c for c in ['Сотрудник', 'Подразделение', 'Должность', 'Начало_дня', 'Статус'] if c in sc_m.columns]
scud_matches = sc_m[cols_scud].to_dict(orient='records')
detailed_cases_for_ai.append({
"fio_clean": fio_clean,
"staff_records_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)
present_cnt = len(merged_df[merged_df['Пришел'] == True])
explained_cnt = len(absent_explained)
unexplained_cnt = len(absent_unexplained)
prompt = f"""
Ты — старший аудитор кадровой безопасности и систем СКУД. Проанализируй данные за {date_str} и составь итоговый отчет.
📌 ДИНАМИЧЕСКИЕ ПРАВИЛА И НАКОПЛЕННЫЙ ОПЫТ ПРЕДПРИЯТИЯ:
{custom_rules_str}
🚨 СТРОГИЕ ПРАВИЛА МАТЕМАТИКИ И КАТЕГОРИЙ:
1. Цифры сводки ДОЛЖНЫ СТРОГО СОВПАДАТЬ с первично посчитанным массивом:
- Всего офисных сотрудников: {total_staff}
- Присутствуют на рабочем месте: {present_cnt}
- Официально отсутствуют: {explained_cnt}
- Неизвестно (истинно неотмеченные): {unexplained_cnt} чел.
2. Заголовок "#### Неизвестные случаи: {unexplained_cnt}" должен иметь ровно ту же цифру {unexplained_cnt}.
3. Нумерованный список ниже ДОЛЖЕН содержать ровно {unexplained_cnt} сотрудников из массива `detailed_cases_for_ai` без пропусков.
---
📊 ВХОДНЫЕ МИКРО-ДАННЫЕ НЕИЗВЕСТНЫХ ({unexplained_cnt} чел):
{json.dumps(detailed_cases_for_ai, ensure_ascii=False, indent=2)}
🚨 СПИСОК КРИТИЧЕСКИХ АНОМАЛИЙ:
{json.dumps(critical_list, ensure_ascii=False, indent=2)}
СТРОГИЙ ШАБЛОН ОТВЕТА:
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str} в 12:00**
- Всего офисных сотрудников: **{total_staff}**
- Присутствуют на рабочем месте: **{present_cnt}**
- Официально отсутствуют: **{explained_cnt}**
- Неизвестно (истинно неотмеченные после проверки ИИ): **{unexplained_cnt}** чел.
#### ⚠️ Подозрения на ложные срабатывания СКУД / Совмещения:
(Укажи сотрудников с аномалиями/вторыми проходами, например Кондрашову Е.В., если у нее дубликат карты)
#### Критические аномалии (Работа в отпуске / на больничном):
(Детально опиши сотрудников из списка КРИТИЧЕСКИХ АНОМАЛИЙ. Если их нет — напиши "Критических нарушений не выявлено.")
#### Неизвестные случаи: {unexplained_cnt}
(Выведи НУМЕРОВАННЫЙ СПИСОК ВСЕХ {unexplained_cnt} человек строго в формате:
1. ФИО — [Аббревиатура], Должность
2. ФИО — [Аббревиатура], Должность
...
{unexplained_cnt}. ФИО — [Аббревиатура], Должность)
### Рекомендации:
...
"""
sys_prompt = "Ты — кадровый аудитор. Выводи ровно то количество людей в списке, которое указано в переменной unexplained_cnt."
return ask_ollama(prompt, system_prompt=sys_prompt)
+3963
View File
File diff suppressed because it is too large Load Diff
+252
View File
@@ -0,0 +1,252 @@
О’кей хорошо! Ниже представлена полностью объединенная и систематизированная история развития проекта **`scud_orion_ai_v2`**. В нее вошел весь путь: от стартовых инцидентов с файлами 1С и отладкой MS SQL Ориона до текущей архитектуры со снапшотами, двойной фильтрацией исключений и SQLite-базой знаний.
---
# 🚀 ПОЛНАЯ ИСТОРИЯ РАЗВИТИЯ И СНАПШОТ ПРОЕКТА: SCUD ORION AI (v2)
**Дата актуализации:** 05.08.2026
**Статус проекта:** Производственный релиз (*Production-Ready*) с поддержкой снапшотов, двойной фильтрацией исключений, лаконичным форматированием и синхронизацией SQLite.
**Общий объем кода:** ~2 620 строк Python (17 модулей + CLI-инструментарий)
---
## 📌 1. НАЗНАЧЕНИЕ И АРХИТЕКТУРА СИСТЕМЫ
Проект **`scud_orion_ai_v2`** — это система автоматизированного кадрового контроллинга и аудита трудовой дисциплины. Система сопоставляет логи физических проходов СКУД «Орион Pro» (MS SQL Server) с кадровыми данными и документами отсутствия из 1С:ЗУП 3.1 (MS SQL Server / Сетевая шара), а также выполняет квалификацию отклонений с помощью локальной LLM (Ollama / Qwen 2.5 14B).
### 💻 Ключевой стек технологий:
* **Язык разработки:** Python 3.12 (в изолированном `venv`).
* **СУБД СКУД (Источник):** MS SQL Server (`Orion-14.01.21-1` на хосте `172.16.31.221\SQL`).
* **СУБД 1С (Источник):** MS SQL Server (`ACCOUNT-01`, БД `zup_db`).
* **Фоллбэк/Обмен:** Сетевая Linux-шара (`/mnt/scud_share`).
* **Локальная СУБД (Проект):** SQLite (`data/scud_orion_ai.db` с поддержкой WAL-режима и истории снапшотов).
* **AI Engine:** Ollama API (`http://192.168.11.3:11434/api/generate`, модель `qwen2.5:14b`).
* **CLI & Репорты:** `pandas`, `openpyxl`, `pyodbc`, `sqlite3`.
---
## 📜 2. ПОЛНАЯ ХРОНОЛОГИЯ ИНЦИДЕНТОВ И РЕШЕНИЙ
### 🔹 Инцидент 1: Ошибка распарсивания Excel-файлов Штатного расписания 1С
* **Симптом:** `KeyError: 'ФИО'` при запуске `main.py` на этапе загрузки штата из `/data/1c/Штат_DD_MM_YYYY.xlsx`.
* **Причина:** Файлы экспорта 1С выгружаются со служебным заголовком. При `skiprows=7` датафрейм воспринимал строку с именами колонок как данные, присваивая столбцам имена `Unnamed: 0`, `Unnamed: 1`.
* **Решение:** Перешли от обращения по текстовым именам колонок к позиционному связыванию через `.iloc[:, [1, 5, 12]]` (индексы: `1` — ФИО, `5` — Подразделение, `12` — Должность) со сдвигом `skiprows=8`.
### 🔹 Инцидент 2: Падение сетевого подключения к MS SQL Server 1С (`Login timeout expired`)
* **Симптом:** Соединение к MS SQL падало по таймауту (`HYT00`), приводя к отсутствию таблицы `zup_absences` в памяти и `KeyError: 'Вид_отсутствия'`.
* **Причина:** В модуле `zup_extractor.py` находились дефолтные тестовые параметры подключения (`192.168.1.10`) вместо продакшн-конфигурации.
* **Решение:**
* Перевели `zup_extractor.py` на прямой импорт параметров из `config.py` (сервер `ACCOUNT-01`).
* В `services/data_loader.py` реализован гибридный фоллбэк: при недоступности MS SQL 1С система автоматически считывает локальные файлы `Отсутствия_DD_MM_YYYY.xlsx` из папки `data/1c/` (копируемые с шары `/mnt/scud_share`).
### 🔹 Инцидент 3: Аномалия УРВ «Орион Pro» (Случай сотрудника Равина В.Э.)
* **Симптом:** В выгрузке `scud_export.py` сотрудник числился присутствовавшим (вход в 14:11:06), а в отчете УРВ «Орион Pro» стоял статус «Нет входа» / 0:00 отработанных часов.
* **Расследование в SSMS:** Был установлен истинный системный ключ в базе Ориона: `pList.ID = 2107` (`pList.TabNumber = 13058`, а связка логов идет по `pLogData.HozOrgan = pList.ID`). Прямой запрос к сырым логам `pLogData` вернул события: `13:51:15` — Выход (Event 28/32), `14:11:06` — Вход (Event 28/32).
* **Причина расхождения:** День сотрудника начался с фиксации Выхода в 13:51 (приход утром без прикладывания карты). Модуль УРВ «Ориона» не смог корректно сопоставить смену, начинающуюся с выхода, и обнулил табель.
* **Вывод для системы:** Первичные физические логи `pLogData` (события 28/32) обладают 100% приоритетом над расчетным модулем УРВ Ориона.
### 🔹 Инцидент 4: Ошибка нулевых показателей при `--skip-export`
* **Причина:** В запросе к SQLite использовалось жесткое равенство по метке времени `snapshot_time`. Если секунды среза отличались, запрос возвращал 0 строк.
* **Решение:** Введены составные ID снапшотов (`20260805-001`, `Y20260805-001`) и ступенчатый fallback-механизм в `core/database.py`: если точного времени нет, подтягивается самый свежий имеющийся срез за указанную дату.
### 🔹 Инцидент 5: Потеря названий отделов и должностей (`—`, `—`)
* **Причина:** Колонки в базе SQLite назывались `department` и `position`, а отчет требовал `Подразделение` и `Должность`.
* **Решение:** В `core/database.py` добавлен автоматический маппинг колонок при считывании из SQLite, а в `main.py` — подтягивание метаданных из 1С:Штат.
### 🔹 Инцидент 6: Раздутый список «Неизвестных случаев» (21 чел $\rightarrow$ 11 чел)
* **Причина:** В 1С отделы записаны полными именами (*«Отдел внутреннего контроля»*), а в `exceptions.json` — аббревиатурами (*«ОВК»*). Из-за этого контролеры ОВК, клининг и подрядчики не находились алгоритмом и попадали в неизвестные.
* **Решение:** Реализована двойная проверка в `main.py`: при объединении данных сохраняется родная аббревиатура из MS SQL СКУД (`department_scud`). Функция `apply_exceptions_from_json` проводит поиск и по имени 1С, и по короткой аббревиатуре СКУД.
---
## 🛡️ 3. АРХИТЕКТУРА БЕЗОПАСНОСТИ И ПРИОРИТЕТОВ
### 👥 Защита от «Полных Тёзок» (Full Name Collision)
1. **Уровень 1 (БД & SQL):** Использование строго уникальных идентификаторов (`pList.ID` в СКУД, GUID/Табельный номер в 1С).
2. **Уровень 2 (Составные ключи):** При мёрдже без прямых ID применяется контекстный ключ:
$$ ext{Composite Key} = ext{fio\_clean} + ext{"\_"} + ext{department} + ext{"\_"} + ext{position}$$
3. **Уровень 3 (AI-Валидатор):** Системный промпт LLM запрещает объединять записи полных тёзок при разнице в отделе или должности.
### ⚙️ Иерархия приоритетов статуса сотрудника:
<div style="text-align:center; margin:1em 0; font-size:1.05em;">
<span class="math">Status = </span>
<span class="math">
 Пришел (по СКУД), если есть вход или активность<br/>
 Документ 1С:ЗУП (Отпуск, Больничный...), высший приоритет над исключениями<br/>
 Причина из static_reason_workers.csv, если указан удаленщик<br/>
 Исключение (ОВК/Подрядчики), если подпадает под exceptions.json и в 1С ПУСТО<br/>
 Неизвестно (Истинно неотмеченный), если офисный сотрудник без проходов и без 1С
</span>
</div>
---
## 📁 4. СТРУКТУРА КАТАЛОГОВ И ИСПРАВЛЕННЫХ МОДУЛЕЙ
```text
scud_orion_ai_v2/
├── config.py # Глобальная конфигурация, пути, параметры SQL и LLM (Ollama)
├── main.py # Главный оркестратор пайплайна (СКУД ⟷ 1С, ИИ-аудит, Excel/MD)
├── exceptions.json # Исключения (подрядчики, ОВК, клининг, списки ФИО)
├── core/
│ ├── __init__.py
│ └── database.py # Схема SQLite, таблицы scud_logs, zup_staff, ai_knowledge_base
├── services/
│ ├── __init__.py
│ ├── scud_export.py # Выгрузка СКУД из MS SQL Орион Pro + расчет первой активности
│ ├── zup_extractor.py # Выгрузка отсутствий из MS SQL 1С:ЗУП 3.1
│ ├── share_copier.py # Копирование XLSX файлов с шары /mnt/scud_share
│ ├── data_loader.py # Загрузка, очистка и маркировка аномалий СКУД/1С
│ ├── data_validator.py # Проверка свежести входных файлов
│ ├── excel_exporter.py # Формирование Excel-сводок и детальных отчетов (.xlsx)
│ ├── ai_verifier.py # Взаимодействие с Ollama (Qwen 2.5): сверка ФИО и сбои
│ ├── text_reporter.py # Генерация итогового Markdown-отчета (.md)
│ ├── feedback_loop.py # Контур обучения ИИ (Human-in-the-Loop)
│ └── knowledge_base.py # Загрузка и пополнение Базы Знаний ИИ через SQLite
├── scripts/
│ ├── __init__.py
│ ├── migrate_db.py # Мигратор схемы SQLite БД
│ └── db_cli.py # CLI утилита управления БД, снапшотами и экспорта в Excel
├── data/ # Локальная SQLite БД, выгрузки 1С, СКУД
│ ├── scud_orion_ai.db
│ └── static_reason_workers.csv
└── output/ # Готовые сводки и отчеты (.xlsx, .md)
```
---
## 🛠 5. СПРАВОЧНИК КОМАНД И ЗАПУСКА
### 1. Главный скрипт (`main.py`)
```bash
python main.py [ФЛАГИ]
```
| Команда / Флаг | Описание |
| :--- | :--- |
| `python main.py` | Обычный полный дневной запуск |
| `python main.py --skip-export` | Расчет по последнему имеющемуся снапшоту из SQLite |
| `python main.py --snapshot 20260805-001` | Расчет строго по ID конкретного снапшота |
| `python main.py -d` | Режим расширенной отладки (DEBUG) |
---
### 2. CLI-утилита инспекции БД (`scripts/db_cli.py`)
> Прототип Сервисного Слоя для будущего REST API.
```bash
python scripts/db_cli.py <КОМАНДА> [ПАРАМЕТР] [ФЛАГИ]
```
| Команда | Описание |
| :--- | :--- |
| `python scripts/db_cli.py scud` | Просмотр логов СКУД за сегодня |
| `python scripts/db_cli.py scud --snapshot 20260805-001` | Просмотр логов конкретного снапшота |
| `python scripts/db_cli.py scud --export-xlsx debug_today` | Экспорт среза СКУД в Excel |
| `python scripts/db_cli.py snapshots` | Список всех снапшотов |
| `python scripts/db_cli.py anomalies` | История найденных аномалий |
| `python scripts/db_cli.py rules` | Правила Базы Знаний ИИ из SQLite |
| `python scripts/db_cli.py dump [output.xlsx]` | Полный дамп всех таблиц БД в Excel |
---
📐 Сквозной цикл работы контроллинга SCUD Orion AI (v2)
🔄 Схема движения данных
┌────────────────────────────────────────┐
│ Входные CLI-параметры main.py │
│ (--skip-export / --snapshot / --debug) │
└───────────────────┬────────────────────┘
│
┌────────────────────────────┴───────────────────────────┐
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────────┐
│ 0. MS SQL Орион Pro (СКУД) │ │ 0.5 Сетевая шара /mnt/scud_share │
│ scud_export.py (Первая акт.) │ │ share_copier.py (Штат и Отсут.) │
└──────────────┬────────────────┘ └─────────────────┬─────────────────┘
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────────┐
│ SQLite: scud_logs │ │ data/1c/Штат & Отсутствия │
│ (Сохранение snapshot_id) │ │ (Файлы Excel за сегодня/вчера) │
└──────────────┬────────────────┘ └─────────────────┬─────────────────┘
│ │
└───────────────────────┬───────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 1. data_validator.py │
│ (Проверка свежести и остановок) │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ 2. data_loader.py │
│ (Фоллбэки + Загрузка + Scud Dept) │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ 3. ИИ-Арбитраж & SQLite KB │
│ (Qwen 2.5 + ai_knowledge_base) │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ 4. Иерархическое сведение │
│ (1С -> CSV -> exceptions.json) │
└─────────────────┬─────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ Данные за ВЧЕРА │ │ Данные за СЕГОДНЯ │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ Detailed Excel (.xlsx) │ │ Summary Excel (.xlsx) │
│ Детальный разбор вчера │ │ + Markdown сводка (.md) │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────┐
│ 5. feedback_loop.py │
│ ( Human-in-the-Loop обучение ИИ ) │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ SQLite: scud_orion_ai.db │
│ (Фиксация логов, аномалий и KB) │
└───────────────────────────────────┘
🛠 Подробное описание каждого этапа
Этап Модуль Описание и выполняемые задачи
0. СКУД Экспорт services/scud_export.py Запрос к MS SQL «Орион Pro», вычисление утреннего входа и первой активности (first_activity), запись в SQLite с присвоением snapshot_id.
0.5. Копирование 1С services/share_copier.py Забор актуальных Excel-файлов Штатного расписания и Отсутствий из Linux-шары /mnt/scud_share.
1. Валидация services/data_validator.py Проверка наличия кадровых данных за вчерашний день. При критическом отсутствии файлов блокирует расчет.
2. Загрузка services/data_loader.py Загрузка датасетов, нормализация ФИО и проброс родных коротких аббревиатур отделов из СКУД (department_scud).
3. ИИ-Аудит services/ai_verifier.py Запрос к локальной LLM Ollama (Qwen 2.5) с подгрузкой контекста правил из SQLite-таблицы ai_knowledge_base. Исправление опечаток ФИО и отслеживание сбоев турникетов (>5%).
4. Сведение main.py Применение иерархии приоритетов: 1С:ЗУП → static_csv → exceptions.json (проверка по 1С и department_scud) → Неизвестные.
5. Отчетность & Обучение
excel_exporter.py
text_reporter.py
feedback_loop.py
Формирование Excel-файлов, сборка текста Markdown с аббревиатурами и запись вердиктов кадровика в SQLite.
## 🔮 6. БУДУЩИЕ ТОЧКИ РОСТА (ДОРОЖНАЯ КАРТА ВЕБ-РЕЛИЗА)
1. **REST API на FastAPI:** Перенос функций из `db_cli.py` в эндпоинты (`/api/v1/snapshots`, `/api/v1/scud/logs`, `/api/v1/anomalies`).
2. **ИИ-Чат с поддержкой Function Calling:** Предоставление Ollama (Qwen 2.5) права прямого вызова эндпоинтов API из чата по запросу пользователя (*«Сформируй срез за 12:00 и экспортируй в Excel»*).
3. **Telegram-уведомления:** Автоматическая отправка алертов администраторам при выявлении массовых сбоев турникетов (>5%).
View File
+19
View File
@@ -0,0 +1,19 @@
import sys
import os
# Добавляем корень проекта в путь поиска модулей Python
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import get_connection
rule_text = (
"Сотрудники, присутствующие в 1С:ЗУП, но отсутствующие в СКУД Орион Pro, "
"являются аномалией синхронизации профилей. ИИ должен запрашивать у СБ статус выдачи пропуска."
)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)", (rule_text,))
conn.commit()
print("✓ Правило успешно внесено в SQLite БД!")
+348
View File
@@ -0,0 +1,348 @@
import os
import sys
import argparse
import sqlite3
import pandas as pd
from datetime import datetime
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR
from core.database import (
get_connection,
get_available_snapshots,
get_all_rules_from_db,
load_scud_from_db_by_snapshot,
get_latest_snapshot_time
)
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def print_stats():
"""Выводит общую статистику по записям в таблицах БД."""
print("\n" + "=" * 60)
print("📊 СТАТИСТИКА БАЗЫ ДАННЫХ SQLITE (scud_orion_ai.db):")
print("=" * 60)
with get_connection() as conn:
cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']
for t in tables:
cursor.execute(f"SELECT COUNT(*) FROM {t}")
cnt = cursor.fetchone()[0]
print(f" • Таблица [{t:<18}]: {cnt:>6} записей")
print("=" * 60 + "\n")
def print_snapshots_list(date_str=None):
"""Выводит реестр снапшотов с отображением даты и точного времени среза."""
rows = get_available_snapshots(date_str)
print("\n" + "=" * 105)
print(f"📸 РЕЕСТР СОХРАНЕННЫХ СНАПШОТОВ (СВЕРХУ СВЕЖИЕ) {'ЗА ЛОГИ ' + date_str if date_str else ''}:")
print("=" * 105)
header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата и время среза':<20} | {'Записей':<8}"
print(header)
print("-" * 105)
if not rows:
print("Снапшотов пока нет.")
print("=" * 105 + "\n")
return
def snapshot_sort_key(row):
snap_id = row[0] or ""
snap_time = row[2] or ""
seq_num = 0
if "-" in snap_id:
parts = snap_id.replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
seq_num = int(parts[1])
return (snap_time, seq_num)
sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True)
for r in sorted_rows:
snap_id = r[0] if r[0] else '----------'
log_date = r[1] if r[1] else '—'
snap_time = r[2] if r[2] else '—'
count = r[3]
time_part = "—"
if snap_time and " " in snap_time:
time_part = snap_time.split(" ")[1]
slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date
if not snap_id.startswith("Y"):
formatted_snap_id = f" {snap_id}"
else:
formatted_snap_id = snap_id
print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}")
print("=" * 105 + "\n")
def delete_snapshot_by_id(snapshot_id: str):
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
deleted_count = cursor.rowcount
conn.commit()
print(f"\n[✓] Успешно удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}\n")
return deleted_count
def delete_snapshots_by_date(date_str: str):
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
deleted_count = cursor.rowcount
conn.commit()
print(f"\n[✓] Успешно удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}\n")
return deleted_count
def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
"""Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX."""
target_date = date_str if date_str else DATE_TODAY
print("\n" + "=" * 90)
if snapshot_id:
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):")
else:
print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):")
print("=" * 90)
df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
if df.empty:
print(f"Записи СКУД не найдены.")
print("=" * 90 + "\n")
return
total = len(df)
present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0
absent_cnt = total - present_cnt
print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}")
print("-" * 90)
cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id']
existing_cols = [c for c in cols_to_show if c in df.columns]
print(df[existing_cols].head(30).to_string(index=False))
if len(df) > 30:
print(f"\n... и ещё {len(df) - 30} строк.")
if export_xlsx:
out_path = export_xlsx if export_xlsx.endswith('.xlsx') else f"{export_xlsx}.xlsx"
if not os.path.isabs(out_path):
out_path = os.path.join(OUTPUT_DIR, out_path)
df.to_excel(out_path, index=False)
print("\n" + "*" * 90)
print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}")
print("*" * 90)
print("=" * 90 + "\n")
def print_absences(date_str=None):
"""Выводит список официально отсутствующих сотрудников из 1С:ЗУП за выбранный день."""
target_date = date_str if date_str else DATE_TODAY
print("\n" + "=" * 90)
print(f"📋 ОФИЦИАЛЬНЫЕ ОТСУТСТВИЯ ИЗ 1С:ЗУП ЗА ДАТУ [{target_date}]:")
print("=" * 90)
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', absence_type as 'Причина отсутствия 1С' FROM zup_absences WHERE absence_date = ? ORDER BY absence_type, fio",
conn,
params=(target_date,)
)
if df.empty:
print(f"Записи об отсутствиях 1С за {target_date} в базе не найдены.")
else:
print(f"Всего зафиксировано документов 1С: {len(df)}")
print("-" * 90)
print(df.to_string(index=False))
print("=" * 90 + "\n")
def print_anomalies():
"""Выводит список аномалий СКУД из БД."""
print("\n" + "=" * 80)
print("🚨 ИСТОРИЯ НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:")
print("=" * 80)
with get_connection() as conn:
df = pd.read_sql_query("SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history ORDER BY id DESC LIMIT 50", conn)
if df.empty:
print("Аномалии не найдены.")
else:
print(df.to_string(index=False))
print("=" * 80 + "\n")
def print_rules():
"""Выводит правила базы знаний ИИ."""
rules = get_all_rules_from_db()
print("\n" + "=" * 80)
print("🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ:")
print("=" * 80)
if not rules:
print("База знаний пуста.")
else:
for idx, r in enumerate(rules, 1):
print(f" {idx}. {r}")
print("=" * 80 + "\n")
def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
"""Дампит всю базу SQLite во многостраничный Excel."""
out_path = os.path.join(OUTPUT_DIR, out_filename)
print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
print(f"[✓] Дамп успешно сохранен: {out_path}\n")
def print_system_prompts():
"""Выводит все системные промпты из базы данных."""
print("\n" + "=" * 80)
print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):")
print("=" * 80)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC")
rows = cursor.fetchall()
if not rows:
print("Таблица system_prompts пуста.")
else:
for r in rows:
print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}")
print("-" * 80)
print(f"{r[4]}\n")
print("=" * 80 + "\n")
def print_session_states():
"""Выводит текущие активные сессии и превью (session_states)."""
print("\n" + "=" * 80)
print("🔄 АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states):")
print("=" * 80)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, updated_at, pending_data FROM session_states")
rows = cursor.fetchall()
if not rows:
print("Таблица session_states пуста (нет активных превью).")
else:
for r in rows:
print(f"Session: {r[0]} | Type: {r[1]} | Updated: {r[2]}")
print("-" * 80)
print(f"Pending Data:\n{r[3]}\n")
print("=" * 80 + "\n")
HELP_TEXT = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
ДОСТУПНЫЕ КОМАНДЫ:
stats -- Общая статистика строк по всем таблицам БД
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
prompts -- Посмотреть системные промпты (system_prompts)
sessions -- Посмотреть активные сессии и превью (session_states)
dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день
ПРИМЕРЫ ЗАПУСКА:
python scripts/db_cli.py stats
python scripts/db_cli.py snapshots 06.08.2026
python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
python scripts/db_cli.py scud --snapshot Y20260805-007
python scripts/db_cli.py absences 07.08.2026
python scripts/db_cli.py prompts
python scripts/db_cli.py sessions
python scripts/db_cli.py snapshot del Y20260805-007
python scripts/db_cli.py snapshot del --day 04.08.2026
python scripts/db_cli.py dump my_dump.xlsx
"""
def main():
if any(arg in sys.argv for arg in ['-h', '--help']):
print(HELP_TEXT)
sys.exit(0)
parser = argparse.ArgumentParser(
description=HELP_TEXT,
formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False
)
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot'], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)")
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
if len(sys.argv) == 1:
print_stats()
return
args = parser.parse_args()
if args.command == 'stats':
print_stats()
elif args.command == 'snapshots':
date_val = args.param or args.action
print_snapshots_list(date_str=date_val)
elif args.command == 'scud':
inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx)
elif args.command == 'absences':
date_val = args.param or args.action
print_absences(date_str=date_val)
elif args.command == 'anomalies':
print_anomalies()
elif args.command == 'rules':
print_rules()
elif args.command == 'prompts':
print_system_prompts()
elif args.command == 'sessions':
print_session_states()
elif args.command == 'dump':
filename = args.param if args.param else "db_dump_full.xlsx"
dump_all_to_excel(filename)
elif args.command == 'snapshot':
if args.action == 'del':
if args.day:
delete_snapshots_by_date(args.day)
elif args.param:
delete_snapshot_by_id(args.param)
else:
print("\n[❌] Ошибка: Не указан ID снапшота или параметр --day для удаления.")
print("Пример: python scripts/db_cli.py snapshot del Y20260805-007\n")
else:
print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
else:
print(f"\n[❌] Ошибка: Неизвестная команда.")
print(HELP_TEXT)
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
import os
import sqlite3
# Автопоиск файла базы данных в проекте
db_path = 'data/scud_orion_ai.db' if os.path.exists('data/scud_orion_ai.db') else 'scud_orion_ai.db'
print("=" * 80)
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
print("=" * 80)
if not os.path.exists(db_path):
print(f"❌ Файл базы данных {db_path} не найден!")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 1. Список всех таблиц и колонок
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [t[0] for t in cursor.fetchall()]
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
print("-" * 80)
for t_name in tables:
cursor.execute(f"PRAGMA table_info({t_name})")
cols = [c[1] for c in cursor.fetchall()]
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
count = cursor.fetchone()[0]
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
# 2. Просмотр правил Базы Знаний
if 'ai_knowledge_base' in tables:
print("\n" + "=" * 80)
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
print("=" * 80)
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
rules = cursor.fetchall()
if not rules:
print("Таблица ai_knowledge_base пуста.")
else:
for r_id, r_text, r_author in rules:
print(f" {r_id}. [{r_author}] {r_text}\n")
conn.close()
print("=" * 80)
+23
View File
@@ -0,0 +1,23 @@
import os
print("=" * 80)
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_ai_v2)")
print("=" * 80)
total_files = 0
total_size = 0
for root, dirs, files in os.walk('.'):
# Исключаем служебные каталоги
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
for f in files:
p = os.path.join(root, f)
size = os.path.getsize(p)
total_files += 1
total_size += size
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
print("-" * 80)
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
print("=" * 80)
+31
View File
@@ -0,0 +1,31 @@
import os
OUTPUT_SNAPSHOT = "project_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
+90
View File
@@ -0,0 +1,90 @@
import sqlite3
import os
from datetime import datetime
DB_PATH = 'data/scud_orion_ai.db'
def parse_to_date(date_str):
"""Надежное преобразование даты логов и времени снапшота в объект date."""
if not date_str:
return None
date_str = str(date_str).split(" ")[0].strip()
if "." in date_str:
try:
return datetime.strptime(date_str, "%d.%m.%Y").date()
except ValueError:
return None
elif "-" in date_str:
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return None
return None
def fix_snapshots():
"""
Переиндексирует снапшоты в таблице scud_logs.
Нумерация веников (001, 002, 003...) сквозная внутри каждого ДНЯ СОЗДАНИЯ.
Префикс Y выставляется только если дата логов < даты создания.
"""
if not os.path.exists(DB_PATH):
print(f"База данных {DB_PATH} не найдена.")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Группируем по уникальным выгрузкам
cursor.execute("""
SELECT snapshot_time, log_date
FROM scud_logs
WHERE snapshot_time IS NOT NULL
GROUP BY snapshot_time, log_date
ORDER BY snapshot_time ASC, log_date ASC
""")
rows = cursor.fetchall()
if not rows:
print("Снапшоты не найдены.")
conn.close()
return
print("=== ИСПРАВЛЕНИЕ СКВОЗНОЙ НУМЕРАЦИИ ВНУТРИ ДНЯ СОЗДАНИЯ ===")
# Счетчик текущего порядкового номера strictly за ДЕНЬ СОЗДАНИЯ (YYYYMMDD)
seq_counters = {}
for snap_time_raw, log_date_raw in rows:
d_snap = parse_to_date(snap_time_raw)
d_log = parse_to_date(log_date_raw)
if not d_snap or not d_log:
continue
date_prefix = d_snap.strftime("%Y%m%d")
# Если дата логов строго раньше даты создания снапшота — ставим Y
is_yesterday = (d_log < d_snap)
prefix = "Y" if is_yesterday else ""
# Приращиваем сквозной счетчик за ЭТОТ ДЕНЬ СОЗДАНИЯ
seq_counters[date_prefix] = seq_counters.get(date_prefix, 0) + 1
seq_num = seq_counters[date_prefix]
new_id = f"{prefix}{date_prefix}-{seq_num:03d}"
cursor.execute(
"UPDATE scud_logs SET snapshot_id = ? WHERE snapshot_time = ? AND log_date = ?",
(new_id, snap_time_raw, log_date_raw)
)
print(f" • Срез создания: {snap_time_raw} | Логи за: {log_date_raw} ===> Назначен ID: [{new_id}]")
conn.commit()
conn.close()
print("\n[✓] Переиндексация внутри дней создания успешно выполнена!")
if __name__ == "__main__":
fix_snapshots()
View File
Binary file not shown.
View File
+246
View File
@@ -0,0 +1,246 @@
import json
import re
import requests
import pandas as pd
from config import OLLAMA_URL, OLLAMA_MODEL
from core.database import get_all_rules_from_db
from core.database import get_department_synonyms_dict, add_department_synonym_to_db
def resolve_department_exception_ai(dept_1c, dept_scud, exception_departments):
"""
Универсальный сопоставитель отделов.
Проверяет 1С, СКУД, локальную базу синонимов SQLite и задействует ИИ для сложных случайных аббревиатур.
"""
if not exception_departments:
return False
d_1c = str(dept_1c).strip().lower() if dept_1c else ""
d_scud = str(dept_scud).strip().lower() if dept_scud else ""
exc_list = [d.strip().lower() for d in exception_departments]
# 1. Прямая проверка: если точное имя или подстрока уже совпали в 1С или СКУД
for exc in exc_list:
if exc and (exc in d_1c or exc in d_scud or d_1c in exc or d_scud in exc):
return True
# 2. Проверка по сохраненной Базе Знаний синонимов из SQLite
synonyms = get_department_synonyms_dict()
for exc in exc_list:
# Если в БД зафиксировано: 'овк' -> 'отдел внутреннего контроля'
full_from_db = synonyms.get(exc, "")
if full_from_db and (full_from_db in d_1c or full_from_db in d_scud):
return True
# 3. Умный ИИ-арбитраж (если отдел спорный и еще не сохранен в БД)
if d_1c or d_scud:
dept_to_check = d_1c if d_1c else d_scud
prompt = f"""
Ты — кадровый аналитик.
Проверь, является ли отдел сотрудника "{dept_to_check}" тем же самым подразделением, что и один из отделов-исключений: {exception_departments}?
Примеры:
- "Отдел внутреннего контроля" — это "ОВК" (Да)
- "Отдел технического обеспечения" — это "ОТО" (Да)
Ответь СТРОГО в формате JSON:
{{
"is_match": true/false,
"matched_exception": "Название из списка исключений",
"explanation": "краткое объяснение"
}}
"""
try:
raw_res = ask_ollama(prompt, system_prompt="Отвечай только валидным JSON.")
match = re.search(r'\{.*\}', raw_res, re.DOTALL)
if match:
data = json.loads(match.group(0))
if data.get("is_match"):
matched_exc = data.get("matched_exception", "").lower()
# Запоминаем открытую ИИ связь в SQLite навсегда!
add_department_synonym_to_db(matched_exc, dept_to_check)
return True
except Exception as e:
print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}")
return False
def ask_ollama(prompt, system_prompt=None):
"""
Универсальная функция отправки запросов к локальной модели Ollama (Qwen 2.5).
"""
payload = {
"model": OLLAMA_MODEL,
"prompt": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt,
"stream": False
}
try:
response = requests.post(OLLAMA_URL, json=payload, timeout=120)
if response.status_code == 200:
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:
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:
return f"⚠️ Ошибка при обращении к Ollama: {e}"
def get_system_rules_context():
"""
Загружает динамические системные правила компании напрямую из Базы Данных SQLite.
"""
rules = get_all_rules_from_db()
if not rules:
return ""
rules_text = "\n".join([f"- {r}" for r in rules])
return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ (ИЗ SQLITE БД):\n{rules_text}\n"
def analyze_scud_mass_failure_ai(df_scud):
"""
Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке.
Если процент аномалий превышает 5% от смены или 10 человек, вызывает ИИ для генерации критического алерта.
"""
if df_scud is None or df_scud.empty:
return None
total_records = len(df_scud)
anomaly_rows = df_scud[df_scud.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY']
anomaly_count = len(anomaly_rows)
if total_records == 0:
return None
anomaly_percent = round((anomaly_count / total_records) * 100, 1)
if anomaly_percent > 5.0 or anomaly_count >= 10:
rules_context = get_system_rules_context()
prompt = f"""
{rules_context}
ВНИМАНИЕ! Проведён анализ смены СКУД:
- Всего записей за смену: {total_records}
- Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью: {anomaly_count} ({anomaly_percent}% от смены)
Сформируй понятное предупреждение для Администратора СКУД и Руководителя.
Объясни, что это критический массовый сбой турникетов/контроллеров входа на КПП, и порекомендуй действия.
"""
alert_text = ask_ollama(prompt, system_prompt="Ты — ИИ-аналитик контроллинга СКУД. Отвечай кратко и строго по делу.")
return {
"is_mass_failure": True,
"anomaly_count": anomaly_count,
"anomaly_percent": anomaly_percent,
"alert_text": alert_text
}
return {
"is_mass_failure": False,
"anomaly_count": anomaly_count,
"anomaly_percent": anomaly_percent,
"alert_text": "Массовых сбоев оборудования не зафиксировано."
}
def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
if not unrecognized_scud_fios or not staff_fios:
return {}
# 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен)
staff_fios_set = set(staff_fios)
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
if not real_unrecognized:
return {}
rules_context = get_system_rules_context()
prompt = f"""
Ты — кадровый аудитор безопасности СКУД.
{rules_context}
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
СТРОГИЕ ПРАВИЛА:
1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон.
2. В СКУД допущена опечатка.
3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией.
4. В поле "warning" опиши обнаруженную опечатку.
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
Формат ответа:
{{
"verified_matches": [
{{
"scud_fio": "ФИО из СКУД",
"staff_fio": "эталон ФИО из Штат 1С",
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
}}
]
}}
"""
raw_response = ask_ollama(
prompt,
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
)
mapping = {}
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
json_str = match.group(0)
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
data = json.loads(json_str)
for item in data.get("verified_matches", []):
scud_f = item.get("scud_fio")
staff_f = item.get("staff_fio")
warn = item.get("warning", "Точное совпадение (без опечаток)")
if scud_f and staff_f:
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
except Exception as e:
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
return mapping
def ai_verify_department_exceptions(unexplained_df, exception_departments):
"""
Локальный ИИ проверяет, не являются ли неотмеченные отделы синонимами отделов-исключений.
"""
if unexplained_df.empty or not exception_departments:
return []
dept_list = unexplained_df['Подразделение'].dropna().unique().tolist()
rules_context = get_system_rules_context()
prompt = f"""
{rules_context}
Ты — кадровый аудитор.
Список отделов-исключений компании: {json.dumps(exception_departments, ensure_ascii=False)}
Список отделов сотрудников, попавших в неизвестные: {json.dumps(dept_list, ensure_ascii=False)}
Определи, какие из отделов сотрудников являются ПОЛНЫМИ НАЗВАНИЯМИ или СИНУНИМАМИ отделов-исключений (например: 'Отдел внутреннего контроля' — это 'ОВК').
Выдай ответ строго в формате JSON:
{{
"matched_departments": ["Название отдела 1", "Название отдела 2"]
}}
"""
raw_response = ask_ollama(prompt, system_prompt="Выдавай только валидный JSON.")
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
data = json.loads(match.group(0))
return data.get("matched_departments", [])
except Exception as e:
print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}")
return []
+243
View File
@@ -0,0 +1,243 @@
import os
import pandas as pd
import warnings
from config import (
normalize_fio, clean_scud_fio_light, load_exceptions,
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
)
# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite
from services.zup_extractor import fetch_zup_absences_from_sql
from core.database import (
load_scud_from_db_by_snapshot,
load_staff_from_db,
load_absences_from_db
)
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
def is_excluded(fio, dept, pos, exceptions):
"""Проверяет, входит ли сотрудник или должность в список исключений."""
fio_clean = normalize_fio(fio)
dept_str = str(dept).strip().upper() if pd.notna(dept) else ""
pos_str = str(pos).strip().lower() if pd.notna(pos) else ""
if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]:
return True
if any(keyword in pos_str for keyword in exceptions["position_keywords"]):
return True
return False
def load_staff_data(date_str):
"""
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
"""
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)
return df_staff
except Exception as e:
print(f"[❌] Ошибка загрузки штата из {filepath}: {e}")
return None
def load_absent_data(date_str):
"""
Загружает документально подтвержденные отсутствия сотрудников
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату,
а также автоматически обогащает их удаленщиками из static_reason_workers.csv.
"""
df_absent = None
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 = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
else:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
except Exception as e:
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
# ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv
try:
from config import DATA_DIR
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if os.path.exists(static_path):
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
new_rows = []
for _, s_row in df_static.iterrows():
if s_row['fio_clean'] not in existing_fios:
new_rows.append({
'fio_clean': s_row['fio_clean'],
'Вид_отсутствия': s_row['reason']
})
if new_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
except Exception as e:
print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}")
return df_absent if not df_absent.empty else None
def load_1c_data_smart(date_str, use_db=False):
"""
Универсальный загрузчик данных 1С (Штат и Отсутствия).
Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД
за целевую дату указанного снапшота.
"""
df_staff = None
df_absent = None
if use_db:
df_staff = load_staff_from_db(date_str)
df_absent = load_absences_from_db(date_str)
# Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL
if df_staff is None:
df_staff = load_staff_data(date_str)
if df_absent is None:
df_absent = load_absent_data(date_str)
return df_staff, df_absent
def process_scud_anomalies(df_scud, exceptions=None):
"""
Вычисляет 'Первую активность' и маркирует аномалии СКУД.
"""
if exceptions is None:
exceptions = load_exceptions()
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None)
status_col = 'Статус' if 'Статус' in df_scud.columns else None
first_activities = []
anomaly_flags = []
is_present_flags = []
for _, row in df_scud.iterrows():
fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip()
fio_clean = normalize_fio(fio)
dept = str(row.get('Подразделение', '')).strip()
pos = str(row.get('Должность', '')).strip()
val_start = str(row.get(start_col, '')).strip() if start_col else ''
val_end = str(row.get(end_col, '')).strip() if end_col else ''
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
raw_first_event = row.get('raw_first_event', None)
if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']:
raw_first_event = None
has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00'])
has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00'])
first_act = '—'
anom_flag = 'NONE'
is_present = not ('отсутствовал' in val_status or has_no_in)
if has_no_in:
if raw_first_event is not None or not has_no_out:
first_act = str(raw_first_event) if raw_first_event else val_end
anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY'
if not is_excluded(fio, dept, pos, exceptions):
is_present = False
else:
first_act = '—'
is_present = False
first_activities.append(first_act)
anomaly_flags.append(anom_flag)
is_present_flags.append(is_present)
df_scud['Первая_активность'] = first_activities
df_scud['anomaly_flag'] = anomaly_flags
df_scud['Пришел'] = is_present_flags
return df_scud
def load_scud_data(target_date_type="today", snapshot_param=None):
"""
Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite.
"""
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.")
return pd.DataFrame()
exceptions = load_exceptions()
if 'fio_clean' not in df_scud.columns:
fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0])
df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str)
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns:
df_scud['Начало_дня'] = df_scud['time_in']
if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns:
df_scud['Конец_дня'] = df_scud['time_out']
if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns:
df_scud['Первая_активность'] = df_scud['first_activity']
if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns:
df_scud['Пришел'] = df_scud['is_present'].astype(bool)
df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions),
axis=1
)
return df_scud
def load_all_data():
"""
Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С.
"""
df_scud_today = load_scud_data(target_date_type="today")
try:
df_scud_yesterday = load_scud_data(target_date_type="yesterday")
except FileNotFoundError:
df_scud_yesterday = df_scud_today.copy()
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY)
return (
df_scud_today,
df_scud_yesterday,
df_staff_today,
df_absent_today,
df_staff_yesterday,
df_absent_yesterday
)
+42
View File
@@ -0,0 +1,42 @@
import os
import time
from datetime import datetime
from config import DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
def check_file_freshness(max_age_hours=24):
"""
Проверяет существование входных файлов 1С и СКУД.
Возвращает флаги доступности файлов за Сегодня и Вчера.
"""
warnings = []
errors = []
# 1. Проверка файлов 1С за СЕГОДНЯ
staff_today = find_dated_file("Штат", DATE_TODAY)
absent_today = find_dated_file("Отсутствия", DATE_TODAY)
has_today_1c = bool(staff_today and absent_today)
if not has_today_1c:
warnings.append(
f"Файлы 1С за СЕГОДНЯ ({DATE_TODAY}) еще не выложены на сетевую шару. "
f"Формирование Ежедневной сводки за сегодня будет временно пропущено."
)
# 2. Проверка файлов 1С за ВЧЕРА (критически важны для детального отчета)
staff_yesterday = find_dated_file("Штат", DATE_YESTERDAY)
absent_yesterday = find_dated_file("Отсутствия", DATE_YESTERDAY)
if not staff_yesterday or not absent_yesterday:
errors.append(
f"Отсутствуют файлы 1С за ВЧЕРА ({DATE_YESTERDAY}) в папке {ZUP_1C_DIR}. "
f"Невозможно построить детальный отчет за прошлую смену."
)
# 3. Проверка файлов СКУД
scud_today = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR])
if not scud_today:
warnings.append(f"Сегодняшний файл СКУД за {DATE_TODAY} еще не сформирован в {SCUD_DIR}")
is_valid = len(errors) == 0
return is_valid, warnings, errors, has_today_1c
@@ -16,6 +16,7 @@ MONTHS_RU = {
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
}
def format_date_ru(date_str):
"""Преобразует дату формата '27.07.2026' в '27 июля 2026'"""
try:
@@ -24,6 +25,7 @@ def format_date_ru(date_str):
except Exception:
return date_str
# Границы ячеек
THIN_SIDE = Side(border_style="thin", color="D3D3D3")
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE)
@@ -34,12 +36,49 @@ FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_typ
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска"
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"]
# Палитра для Детального отчета
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске)
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", 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):
@@ -93,22 +132,51 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
current_row = 4
# 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, нумерация 1. ФИО)
unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())]
# Подготовка флагов для точной фильтрации
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
is_exc = merged_df.get('is_excluded', False) == True
# 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана")
unexplained = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass) &
(~is_exc)
]
ws.cell(row=current_row, column=1, value="неизвестно")
ws.cell(row=current_row, column=2, value=len(unexplained))
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
current_row += 1
for idx, fio in enumerate(sorted(unexplained['Сотрудник'].dropna().unique()), 1):
ws.cell(row=current_row, column=1, value=f"{idx}. {fio}")
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False
current_row += 1
# 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
absent_only = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna())]
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок)
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
ws.cell(row=current_row, column=1, value="Нет пропуска")
ws.cell(row=current_row, column=2, value=len(no_pass_df))
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font)
current_row += 1
if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False
current_row += 1
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
absent_only = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_groups = absent_only.groupby('Вид_отсутствия')
for idx_cat, (cat_name, group) in enumerate(absent_groups):
@@ -120,14 +188,14 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font)
current_row += 1
for idx, fio in enumerate(sorted(group['Сотрудник'].dropna().unique()), 1):
ws.cell(row=current_row, column=1, value=f"{idx}. {fio}")
for fio in sorted(group['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, cat_fill, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 🎯 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа)
# 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
)
@@ -138,26 +206,42 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
current_row += 1
# 🚨 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список)
# Столбец A: f"{idx}. {fio} (На работе)"
# Столбец B: f"В 1С: {reason}"
# Ширина столбца B = 32.0 (ровно 230px). Автоперенос + динамическая высота строк.
anomalies = merged_df[(merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())]
if not present.empty:
for fio in sorted(present['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
anomalies = merged_df[
(~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) |
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
)
]
ws.cell(row=current_row, column=1, value="Аномалии")
ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С")
ws.cell(row=current_row, column=2, value=len(anomalies))
format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font)
current_row += 1
chars_per_line_b = 30 # Влезает символов в столбец B шириной 230px (32.0 units)
chars_per_line_b = 30
if not anomalies.empty:
for idx, (_, row) in enumerate(anomalies.iterrows(), 1):
for _, row in anomalies.iterrows():
fio = row.get('Сотрудник', '')
reason = row.get('Вид_отсутствия', 'Неизвестная причина')
reason_text = f"В 1С: {reason}"
reason = row.get('Вид_отсутствия', '')
anom_flag = row.get('anomaly_flag', 'NONE')
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})"
else:
reason_text = f"В 1С: {reason}"
cell_a = ws.cell(row=current_row, column=1, value=f"{idx}. {fio} (На работе)")
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY
@@ -165,11 +249,9 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
# Автоперенос текста в столбце B
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center")
# Адаптивный расчет высоты строки
if len(reason_text) > chars_per_line_b:
lines_count = math.ceil(len(reason_text) / chars_per_line_b)
ws.row_dimensions[current_row].height = max(lines_count * 18, 22)
@@ -177,12 +259,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].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True # Свернут по умолчанию
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 📏 Настройка ширины колонок: B = 32.0 (ровно 230 пикселей)
ws.column_dimensions['A'].width = 45.0
ws.column_dimensions['B'].width = 32.0 # Ровно 230px в Excel
ws.column_dimensions['B'].width = 38.0
try:
wb.save(output_path)
@@ -210,7 +291,7 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
ws["D2"].font = Font(name="Arial", size=10, bold=True)
headers = [
"№", "ФИО", "Подразделение", "время входа", "время выхода",
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
]
ws.append([])
@@ -228,33 +309,50 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня'
hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании'
chars_per_line_g = 24
chars_per_line_h = 24
for idx, row in merged_df.reset_index(drop=True).iterrows():
is_present = row.get('Пришел', False)
absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
anom_flag = row.get('anomaly_flag', 'NONE')
in_building_str = str(row.get(hours_col, '00:00'))
first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
ws.append([
idx + 1,
row.get('Сотрудник', ''),
row.get('Подразделение', ''),
dept_scud_val,
row.get(start_col, 'Нет входа'),
first_act_val,
row.get(end_col, 'Нет выхода'),
row.get(hours_col, '0:00'),
in_building_str,
absence_reason if has_reason else '',
8,
"-8:00" if not is_present else "0:00"
deviation_val
])
row_num = 5 + idx
row_fill = None
if not is_present:
row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL
# ЗАЛИВКА СТРОК:
if is_present and has_reason:
row_fill = GREEN_FILL
elif not is_present and has_reason:
row_fill = YELLOW_FILL
elif not is_present and not has_reason and not has_first_act:
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы)
row_fill = LIGHT_RED_FILL
else:
row_fill = None
val_g_str = str(absence_reason) if has_reason else ""
if len(val_g_str) > chars_per_line_g:
needed_lines = math.ceil(len(val_g_str) / chars_per_line_g)
val_h_str = str(absence_reason) if has_reason else ""
if len(val_h_str) > chars_per_line_h:
needed_lines = math.ceil(len(val_h_str) / chars_per_line_h)
ws.row_dimensions[row_num].height = max(needed_lines * 18, 22)
else:
ws.row_dimensions[row_num].height = 20
@@ -265,25 +363,28 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
if row_fill:
cell.fill = row_fill
if col_idx == 7:
if col_idx == 8:
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
elif col_idx in [1, 4, 5, 6, 8, 9]:
elif col_idx in [1, 4, 5, 6, 7, 9, 10]:
cell.alignment = Alignment(horizontal="center", vertical="center")
else:
cell.alignment = Alignment(horizontal="left", vertical="center")
# Динамический компактный автоподгон ширины колонок
for col in ws.columns:
col_letter = get_column_letter(col[0].column)
if col_letter == 'G':
ws.column_dimensions['G'].width = 25.0
else:
max_len = 0
for cell in col:
if cell.value is not None:
line_max = max(len(l) for l in str(cell.value).split("\n"))
if line_max > max_len:
max_len = line_max
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
max_len = 0
for cell in col:
if cell.value is not None:
cell_lines = str(cell.value).split("\n")
line_max = max(len(line) for line in cell_lines)
if line_max > max_len:
max_len = line_max
optimal_width = max(max_len + 2, 8)
if optimal_width > 35:
optimal_width = 35
ws.column_dimensions[col_letter].width = optimal_width
try:
wb.save(output_path)
+54
View File
@@ -0,0 +1,54 @@
import os
import json
from core.database import add_rule_to_db
try:
from services.knowledge_base import save_rule
except ImportError:
try:
from services.knowledge_base import add_rule as save_rule
except ImportError:
save_rule = None
def review_ai_decisions(report_text, suspicious_cases):
"""
Интерактивный консольный модуль обучения (Human-in-the-Loop).
Запрашивает подтверждение оператора ТОЛЬКО по нетипичным/сложным случаям.
"""
if not suspicious_cases:
print("\n[✓] Все аномалии валидированы автоматически. Спорных вопросов для оператора нет.")
return
print("\n" + "=" * 60)
print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)")
print("=" * 60)
for idx, case in enumerate(suspicious_cases, 1):
fio_target = case.get('fio_target', 'Неизвестно')
reason = case.get('reason', 'Нет описания')
print(f"\n[?] Спорный случай {idx}/{len(suspicious_cases)}: {fio_target}")
print(f" Детали: {reason}")
user_input = input("Подтверждаете аномалию? (д / да / н / нет / или введите текст правила): ").strip()
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:
# Если оператор обучает систему синониму отделов
if "отдел" in user_input_clean or "овк" in user_input_clean:
from core.database import add_department_synonym_to_db
add_department_synonym_to_db(user_input.strip(), "отдел внутреннего контроля")
new_rule = f"Правило: {user_input}"
add_rule_to_db(new_rule, added_by="Human_Admin")
print(f" [✓] Новое правило записано в Базу Знаний SQLite: {new_rule}")
else:
print(" [ℹ️] Запись пропущена.")
print("\n[✓] ЭТАП ОБУЧЕНИЯ ЗАВЕРШЕН.")
+23
View File
@@ -0,0 +1,23 @@
import os
from core.database import get_all_rules_from_db, add_rule_to_db
def load_knowledge_base():
"""
Загружает актуальные правила Базы Знаний компании напрямую из базы данных SQLite.
"""
rules = get_all_rules_from_db()
return {
"rules": rules if rules else [],
"fio_corrections": {}
}
def add_rule_to_kb(new_rule):
"""
Добавляет новое принятое человеком правило в SQLite таблицу ai_knowledge_base.
"""
if not new_rule or not new_rule.strip():
return
add_rule_to_db(new_rule.strip(), added_by="Human")
print(f"[✓] База знаний SQLite успешно обновлена! Новое правило: {new_rule.strip()}")
@@ -1,11 +1,6 @@
"""
Скрипт экспорта данных СКУД (Orion) в Excel.
Оптимизированная супер-быстрая версия (использует ROW_NUMBER() вместо подзапросов).
Запуск:
python scud_export.py
python scud_export.py -d
python scud_export.py --date 28.07.2026 -d
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel.
Добавлена фиксация Первой Активности (без учета направления) и среза по времени.
"""
import argparse
@@ -19,13 +14,13 @@ import pandas as pd
import pyodbc
from openpyxl.utils import get_column_letter
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot
warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable")
# ==============================================================================
# 1. Настройки путей и логов
# ==============================================================================
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)
TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y")
@@ -34,13 +29,14 @@ LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
logger = logging.getLogger("scud_export")
logger.setLevel(logging.INFO)
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
_console_handler = logging.StreamHandler(sys.stdout)
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
_console_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
logger.addHandler(_console_handler)
if not logger.handlers:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
_console_handler = logging.StreamHandler(sys.stdout)
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
_console_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
logger.addHandler(_console_handler)
def log(message: str, level: str = "INFO"):
@@ -56,21 +52,12 @@ def log(message: str, level: str = "INFO"):
logger.log(level_map.get(level, logging.INFO), message)
# ==============================================================================
# 2. Настройки базы данных и папки
# ==============================================================================
SERVER_NAME = r"192.168.1.253\SQL"
SERVER_NAME = r"172.16.31.221\SQL"
DATABASE_NAME = "Orion-14.01.21-1"
SQL_USER = "sa"
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"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @TargetDate DATE = @InputDate;
@@ -78,27 +65,29 @@ DECLARE @TargetDate DATE = @InputDate;
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
-- 1. Фильтруем логи только за нужные 24 часа за один проход
WITH DailyLogs AS (
SELECT
log.HozOrgan AS EmployeeID,
log.TimeVal,
log.Event,
log.Mode,
CASE WHEN log.Event IN (28, 26, 32) OR log.Mode = 1 THEN 'IN'
WHEN log.Event IN (29, 27, 33) OR log.Mode = 2 THEN 'OUT'
ELSE 'OTHER' END AS Direction,
CASE
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
ELSE 'OTHER'
END AS Direction,
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
FROM pLogData log WITH (NOLOCK)
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND (log.Event IN (28, 29, 26, 27, 32, 33) OR log.Mode IN (1, 2))
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
),
-- 2. Агрегируем времена входа/выхода и определяем последнее событие
Passages AS (
SELECT
EmployeeID,
MIN(TimeVal) AS FirstRawEvent,
MAX(TimeVal) AS LastRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
@@ -119,26 +108,33 @@ SELECT
ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№],
CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата],
ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня],
CASE
WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL
THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20))
ELSE N'—'
END AS [Первая_активность],
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
THEN N'Нет выхода'
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
ELSE N'Нет выхода'
END AS [Конец_дня],
CASE
WHEN pass.EmployeeID IS NOT NULL AND pass.FirstIn IS NOT NULL THEN
WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN
RIGHT('0' + CAST(DATEDIFF(MINUTE,
pass.FirstIn,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.FirstIn)
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
END) / 60 AS VARCHAR), 2) + ':' +
RIGHT('0' + CAST(DATEDIFF(MINUTE,
pass.FirstIn,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.FirstIn)
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
END) % 60 AS VARCHAR), 2)
ELSE N'00:00'
END AS [Находился_в_здании],
@@ -167,9 +163,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):
from openpyxl import load_workbook
@@ -196,7 +189,6 @@ def get_targets(input_date: str | None):
try:
parsed = datetime.strptime(input_date, "%d.%m.%Y").date()
targets.append({"name": "Указанная дата", "date": parsed})
log(f"Запущен ручной режим для даты: {input_date}")
except ValueError:
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
sys.exit(1)
@@ -211,19 +203,15 @@ def get_targets(input_date: str | None):
return targets
# ==============================================================================
# 5. Основная функция выгрузки
# ==============================================================================
def run_export(input_date: str | None, debug: bool = False):
def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False):
if debug:
logger.setLevel(logging.DEBUG)
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
log("=== Запуск скрипта экспорта СКУД ===")
os.makedirs(FOLDER_PATH, exist_ok=True)
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
os.makedirs(SCUD_DIR, exist_ok=True)
targets = get_targets(input_date)
conn_str = (
f"DRIVER={{{ODBC_DRIVER}}};"
f"SERVER={SERVER_NAME};"
@@ -234,24 +222,25 @@ def run_export(input_date: str | None, debug: bool = False):
f"Encrypt=no;"
)
success = True
for target in targets:
processing_date = target["date"]
processing_date_str = processing_date.strftime("%d.%m.%Y")
period_label = target["name"]
is_yesterday = (period_label == "Вчера")
log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---")
# ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE:
# Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL
if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
continue
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
if is_yesterday:
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00"
else:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]")
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
connection = None
@@ -261,51 +250,62 @@ def run_export(input_date: str | None, debug: bool = False):
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:
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl")
auto_fit_columns(file_path, sheet_name="Отчет")
log(f"Успешно создан файл {file_path}", "SUCCESS")
df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light)
df['anomaly_flag'] = 'NONE'
mask_anomaly = (df['Начало_дня'] == 'Нет входа') & (df['Первая_активность'] != '—')
df.loc[mask_anomaly, 'anomaly_flag'] = 'ANOMALY_NO_IN_HAS_ACTIVITY'
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly)
save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday)
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
if save_xlsx:
file_name = f"Сотрудники_{processing_date_str}.xlsx"
file_path = os.path.join(SCUD_DIR, file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
except OSError as e:
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl")
auto_fit_columns(file_path, sheet_name="Отчет")
log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
else:
log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
except Exception as e:
log(f"ОШИБКА при обработке даты {processing_date_str}: {e}", "ERROR")
log(f"🛑 ОШИБКА выгрузки СКУД за {processing_date_str}: {e}", "ERROR")
success = False
finally:
if connection is not None:
connection.close()
log("=== Завершение работы скрипта ===")
log("=== Выгрузка СКУД завершена ===\n")
return success
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Экспорт данных СКУД в Excel")
parser.add_argument(
"--date",
dest="input_date",
default=None,
help='Дата в формате ДД.ММ.ГГГГ (например, "28.07.2026")',
)
parser.add_argument(
"-d", "--debug",
action="store_true",
help="Запуск в режиме отладки (вывод детальной статистики)",
help_text = """
Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel.
ПРИМЕРЫ ЗАПУСКА:
python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера
python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату
python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX
python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)")
parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/")
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
+142
View File
@@ -0,0 +1,142 @@
import json
import difflib
import re
from services.ai_verifier import ask_ollama
from services.knowledge_base import load_knowledge_base
def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
"""
Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ).
Сравнивает фамилию и полное имя с порогом сходства >= 0.75.
"""
if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty:
return []
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="05.08.2026"):
# 1. Загрузка Базы Знаний
kb = load_knowledge_base()
custom_rules = kb.get("rules", [])
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
# 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff)
total_staff = len(merged_df)
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)
unexplained_cnt = len(absent_unexplained)
anomalies_cnt = len(anomalies_list) if anomalies_list else 0
# 3. Точный расчет неточных совпадений через Python
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
# 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД
unexplained_list = []
if not absent_unexplained.empty:
for idx, r in absent_unexplained.iterrows():
fio = r.get('Сотрудник', r.get('fio_clean', ''))
dept_scud = str(r.get('department_scud', '')).strip()
dept_1c = str(r.get('Подразделение', '')).strip()
dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c
if not dept:
dept = '—'
pos = r.get('Должность', '—')
unexplained_list.append(f"{fio} — {dept}, {pos}")
# 5. Сборка промпта для Ollama
prompt = f"""
Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
Сформируй итоговую сводку кадрового контроля на {date_str}.
📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ:
{custom_rules_str}
🚨 ВХОДНЫЕ МЕТРИКИ:
- Всего сотрудников: {total_staff}
- Работают (офис / удаленка / командировки): {present_cnt}
- Официально отсутствуют: {explained_cnt}
- Неизвестно (истинно неотмеченные): {unexplained_cnt}
- Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
---
🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
{json.dumps(anomalies_list, ensure_ascii=False, indent=2)}
---
⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт):
{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)}
---
📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел):
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций.
СТРОГИЙ ШАБЛОН ОТВЕТА:
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
- Всего офисных сотрудников: **{total_staff}**
- Работают (офис / удаленка / командировки): **{present_cnt}**
- Официально отсутствуют: **{explained_cnt}**
- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.")
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.")
#### Неизвестные случаи: {unexplained_cnt}
(Выведи нумерованный список всех {unexplained_cnt} человек)
### Точечные рекомендации:
(2-3 системные рекомендации без указания ФИО сотрудников)
"""
sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке по предоставленному Markdown-шаблону. Категорически запрещено выводить JSON или китайские символы."
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
+1
View File
@@ -0,0 +1 @@
122222222222222222222222222222
+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()
@@ -0,0 +1,61 @@
# ТЕХНИЧЕСКИЙ РАЗБОР АНОМАЛИИ СКУД И ТЗ НА ФИКС
> **Проблема скрытого маскирования сбоев турникетов и правила их устранения в проекте `scud_orion_ai`**
---
### 📋 Метаданные инцидента
| Параметр | Значение |
| :--- | :--- |
| **Дата инцидента:** | 04 августа 2026 г. |
| **Затронуто сотрудников:** | ~119 человек (из 276) |
| **Система:** | СКУД «Орион Pro» (MSSQL) + Python ETL + SQLite |
| **Статус решения:** | Требуется фикс валидации данных (В процессе) |
---
## 1. Суть проблемы («Грабли»)
При утреннем сбое турникетов или контроллеров СКУД (08:00–10:00) события первичного прохода в базу не попадают. В результате у сотрудников в итоговом отчете формируется значение `Начало_дня = «Нет входа»`.
Однако в течение дня сотрудники перемещаются внутри здания, выходят на обед или прикладывают карты к внутренним считывателям, генерируя сырые транзакции в MSSQL (таблица `pLogData`).
> 🚨 **Главная баг-логика текущего алгоритма:**
> Скрипт перебирает все события за день и, видя любую дневную/вечернюю активность, **автоматически присваивает сотруднику флаг `is_present = 1` (`Статус = «Присутствовал»`)**.
>
> В итоге система «тихо сглаживает» аномалию: вместо того чтобы поднять тревогу о массовом сбое турникетов (70+ человек без утреннего входа), скрипт отправил 30+ человек в «Итого на работе», искусственно уменьшив список проблемных записей до 7 «неизвестных».
---
## 2. Разбор механики расхождения отчетов (17:00 vs Конец дня)
Вопреки первоначальной гипотезе, вечерние выходы (17:42 и т.д.) у данных сотрудников не записывались в колонку `time_out` (там оставалось «Нет выхода»). Расхождение выгрузок было вызвано двумя факторами:
1. **В 17:00** в базе SQL 1С еще отсутствовал полный пакет кадровых документов за день, либо запуск производился без сверки с реестром исключений.
2. **При повторном прогоне** (вечером/ночью) скрипт подтянул полную базу 1С, а для оставшихся «безвходных» отработал скрытый флаг `is_present = 1`, маскируя технический сбой СКУД.
---
## 3. Техническое задание на исправление (Правила фикса)
* **Строгая валидация флага присутствия (Data Sanitization):**
Если `time_in == 'Нет входа'` и сотрудник отсутствует в `static_reason_workers.csv` (статичные удалёнщики/внештатники), код **ЗАПРЕЩАЕТ** автоматически проставлять `is_present = 1`, даже если выгрузка/агрегатор передает статус «Присутствовал».
* **Детектор аномалий и «Красный флаг» (Anomaly Detection):**
Если у человека `time_in == 'Нет входа'`, но при этом есть дневные проходы в `pLogData`, система помечает его статусом:
`🚨 АНОМАЛИЯ СКУД: Присутствие без утреннего входа (Зафиксирована активность в течение дня)`.
* **Группировка массовых сбоев:**
Если процент людей с подобной аномалией превышает **5% от штата**, ИИ/модуль аналитики генерирует системный алерт:
`«Внимание! Зафиксирован массовый сбой регистраторов входа СКУД»`.
* **Параметризация MSSQL СКУД по времени (`@CutoffDateTime`):**
Добавить возможность делать срезы базы СКУД на конкретный час (например, 17:00), чтобы видеть честное состояние контроллеров и транзакций на выбранный момент времени:
```sql
DECLARE @CutoffDateTime DATETIME = '2026-08-04 17:00:00';
SELECT * FROM pLogData WHERE TimeVal <= @CutoffDateTime;
Хранение истории выгрузок (snapshot_time):
Запретить перезапись (REPLACE) локальной БД SQLite в рамках одного дня. Каждая выгрузка за день должна сохраняться с меткой времени snapshot_time, чтобы сохранять историю срезов (12:00, 17:00, 19:00).
Документ зафиксирован в рамках проекта scud_orion_ai_v2.