docs: update CHANGELOG and ROADMAP with section 10 (svodka/otchet split, Y-23:59:59)

This commit is contained in:
2026-08-27 22:43:45 +03:00
parent a9680db0aa
commit fa386b3b79
10 changed files with 483 additions and 256 deletions
+30 -16
View File
@@ -62,7 +62,7 @@ def init_all_tables() -> None:
); );
""") """)
# 3. Аномалии и база знаний # 3. Аномалии, база знаний и синонимы
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history ( CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -93,7 +93,34 @@ def init_all_tables() -> None:
); );
""") """)
# 4. Узлы системного промпта и сессии # 4. Исключения и Кэш сопоставлений личностей (ИИ / Ручной)
cursor.execute("""
CREATE TABLE IF NOT EXISTS exceptions_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
value TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category, value)
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI', -- 'AI', 'MANUAL', 'EXACT'
status TEXT DEFAULT 'ACTIVE', -- 'ACTIVE', 'PROPOSED', 'REJECTED'
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
# 5. Узлы системного промпта, сессии, сообщения и задачи
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS system_prompt_nodes ( CREATE TABLE IF NOT EXISTS system_prompt_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -127,18 +154,6 @@ def init_all_tables() -> None:
); );
""") """)
cursor.execute("""
CREATE TABLE IF NOT EXISTS exceptions_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL, -- 'department', 'position', 'fio', 'position_keyword', 'include_fio', 'remote_worker'
value TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category, value)
);
""")
# 5. Задачи
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -156,7 +171,6 @@ def init_all_tables() -> None:
# 6. Индексы # 6. Индексы
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") 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_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);") cursor.execute("CREATE INDEX IF NOT EXISTS idx_mapping_scud ON person_identity_mapping(scud_fio);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
conn.commit() conn.commit()
Binary file not shown.
+61 -23
View File
@@ -1,6 +1,58 @@
# 📋 История изменений (CHANGELOG) # 📋 История изменений (CHANGELOG)
Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6]. Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле.
## [3.2.0] — 2026-08-27
### 🧠 Интеллектуальный арбитраж личностей, агрегация мульти-пропусков и автозакрытие смен
#### ✨ Добавлено:
- **Кэш сопоставлений личностей (`person_identity_mapping`)**:
- Создана таблица в SQLite (`core/schema.py`) для постоянного хранения подтвержденных связок между сырыми ФИО СКУД и эталонными ФИО 1С:ЗУП.
- Поддержка мгновенного извлечения проверенных сопоставлений в `merger.py` (время выборки < 1 мс).
- **Агрегация дубликатов пропусков и мульти-учеток СКУД (`merger.py`)**:
- Функция `aggregate_scud_by_person` объединяет все карты/записи одного физлица: фиксирует самый ранний вход `min(valid_ins)`, самый поздний выход `max(valid_outs)` и итоговый статус присутствия.
- Исключено ложное попадание сотрудников с несколькими пропусками (например, Кондрашова Е.В.) в категорию «неизвестно».
- **Интеллектуальный выбор ставки совместителей 1С (`merger.py`)**:
- Функция `select_best_zup_position` сопоставляет множественные должности одного сотрудника в 1С со СКУД и подтягивает ставку того подразделения, где человек находится физически.
- **Автозакрытие незакрытых смен по Правилу 8.5ч (`excel_exporter.py`)**:
- При наличии утреннего входа и отсутствии вечернего выхода у офисных сотрудников в детальном отчете за вчера расчетный выход ставится как `Вход + 8ч 30мин`, норма 8 часов и нулевое отклонение `0:00` (вместо ложного штрафа `-8:00`).
- **Теневой ИИ-арбитраж нераспознанных ФИО (`text_reporter.py`)**:
- Модуль `find_ai_identity_suggestions` выявляет нечеткие совпадения и опечатки операторов СКУД, выводя рекомендации для кадровой службы в блоке «💡 Предложения ИИ по сопоставлению ФИО».
- **Расширение CLI-утилиты (`scripts/db_cli.py`)**:
- Добавлены команды управления связками: `python scripts/db_cli.py mapping [list|add|del]`.
#### 🔧 Изменено:
- **`services/scud_etl/anomaly_detector.py`**:
- Добавлено авто-детектирование задвоенных карточек СКУД (`DUPLICATE_SCUD_CARD`) с передачей информации в ИИ-сводку.
- **`main_etl.py`**:
- Восстановлена передача сырых датасетов (`raw_scud_df`, `raw_staff_df`, `raw_absent_df`) в ИИ-аудитор и генерация диагностического дампа `СКУД_Сырые_данные_ДД.ММ.ГГГГ.xlsx`.
---
## [3.1.0] — 2026-08-25
### 🛡️ Реляционный реестр исключений, Белый список и отказоустойчивость выгрузок
#### ✨ Добавлено:
- **Реляционный реестр исключений (`exceptions_registry`)**:
- Перенос исключений (`departments`, `positions`, `fio`, `position_keywords`) и белого списка (`include_fio`) из статического JSON в базу данных SQLite (`core/schema.py`).
- Репозиторий `services/exceptions_repo.py` с функциями CRUD и авто-миграцией из `exceptions.json`.
- **Белый список (`include_fio`)**:
- Поддержка явного включения сотрудников в расчет контроллинга в обход должностных и отдельских фильтров (Тарасенко А.А., Журиков М.Н.).
- **Управление исключениями в CLI (`scripts/db_cli.py`)**:
- Добавлены команды `exceptions list`, `exceptions add -c ... -v ...`, `exceptions del`, `exceptions sync`.
- **Резервный загрузчик кадровых отсутствий (`data_loader.py`)**:
- Реализован каскадный fallback: `MS SQL 1C:ЗУП` (таймаут 30с) $\rightarrow$ резервный парсинг скопированных Excel-файлов `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`.
#### 🔧 Изменено:
- **Структура отчетов Excel (`excel_exporter.py`)**:
- Изменен базовый путь сохранения отчетов на `output/reports/ГОД/МЕСЯЦ/`.
- В Ежедневной сводке блок «В том числе на удаленной работе» вынесен под «Итого на работе», а блок «Исключения» размещен в самом низу с поддержкой группировки.
- Добавлена защита от блокировок Excel (`PermissionError`/`OSError`): при занятости файла создается резервная копия с временной меткой.
- **Подключение к СКУД (`services/scud_export.py`)**:
- Актуализирован адрес сервера MS SQL Орион (`172.16.200.147\SQL`).
- Добавлена защита инициализации файлового логгера от ошибок доступа прав пользователей.
---
## [3.0.0] — 2026-08-21 ## [3.0.0] — 2026-08-21
### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture) ### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture)
@@ -71,42 +123,28 @@
- Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`. - Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`.
* **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий. * **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий.
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**:
- Прямой просмотр промпта (`db_get_system_prompt`) переведен в прозрачный режим без блокировки строки ввода и без лишних кнопок.
- В инструмент `db_prompt_node_edit` добавлена передача исходного `baseline_prompt` для точного сопоставления правок в UI.
* **`modules/web_api/llm/core/fast_path.py`**: Оптимизирована зачистка эфемерных сообщений при кликах на кнопки фазы follow-up.
--- ---
## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling ## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling
### ✨ Добавлено ### ✨ Добавлено
* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`[cite: 6]. * **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`.
* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling[cite: 6]. * **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling.
### 🔧 Изменено
* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя[cite: 6].
* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога[cite: 6].
--- ---
## [2026-08-15] — Реляционная архитектура системного промпта ## [2026-08-15] — Реляционная архитектура системного промпта
### ✨ Добавлено ### ✨ Добавлено
* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`)[cite: 6]. * **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`).
* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта[cite: 6]. * **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта.
* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`[cite: 6]. * **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`.
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite[cite: 6].
* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных[cite: 6].
--- ---
## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога ## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога
### ✨ Добавлено ### ✨ Добавлено
* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков[cite: 6]. * **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков.
* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций[cite: 6]. * **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций.
* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия[cite: 6]. * **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия.
+98 -73
View File
@@ -1,58 +1,58 @@
# План реализации (ROADMAP) # План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`) ## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. - [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 3].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 4]. - [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 3].
--- ---
## 2. Настройка контекста и инструкций сессии (`agent.py`) ## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]`
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4]. - [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 3].
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4]. - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 3].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4]. - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 3].
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4]. - [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 3].
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 4]. - [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 3].
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 4]. - [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 3].
--- ---
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) ## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]`
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. - [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 3].
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. - [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 3].
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. - [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 3].
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 4]. - [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 3].
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 4]. - [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 3].
--- ---
## 4. Инлайн-редактор в окне диалога (`core.js`) ## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]`
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. - [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 3].
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. - [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 3].
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 4]. - [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 3].
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 4]. - [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 3].
--- ---
## 5. Тестирование и валидация системного промпта ## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]`
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. - [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 3].
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. - [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 3].
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. - [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 3].
--- ---
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) ## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]`
- [x] **Масштабирование UI задач:** - [x] **Масштабирование UI задач:**
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 4]. - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 3].
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 4]. - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 3].
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 4]. - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 3].
- [x] **Инлайн-редактирование карточки задачи:** - [x] **Инлайн-редактирование карточки задачи:**
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 4]. - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 3].
- Отображение даты создания задачи[cite: 4]. - Отображение даты создания задачи[cite: 3].
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 4]. - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 3].
- [x] **Детерминированный Fast-Path и двухфазное удаление:** - [x] **Детерминированный Fast-Path и двухфазное удаление:**
- Мгновенная смена статусов без задержек LLM[cite: 4]. - Мгновенная смена статусов без задержек LLM[cite: 3].
- Карточка подтверждения удаления с автоочисткой контекста[cite: 4]. - Карточка подтверждения удаления с автоочисткой контекста[cite: 3].
- [x] **Генерация отчетов задач в Markdown:** - [x] **Генерация отчетов задач в Markdown:**
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3]. - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3].
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3]. - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3].
@@ -61,9 +61,9 @@
--- ---
## 7. Распространение на модуль снапшотов (`snapshots`) ## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]`
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 4]. - [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 3].
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 4]. - [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 3].
- [x] **Интерактивный UI с чекбоксами и защитой срезов:** - [x] **Интерактивный UI с чекбоксами и защитой срезов:**
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3]. - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3].
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3]. - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3].
@@ -74,50 +74,75 @@
--- ---
## 🏗️ Раздел 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]` ## 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
- [x] **Выделение общего слоя ядра (`core/`):**
- [x] **Шаг 1. Выделение общего слоя ядра (`core/`)** - Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов.
- [x] Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов. - DDL-схемы таблиц и индексов (`core/schema.py`).
- [x] DDL-схемы таблиц и индексов (`core/schema.py`). - Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`).
- [x] Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`). - Фасад обратной совместимости (`core/database.py`).
- [x] Фасад обратной совместимости (`core/database.py`). - [x] **Декомпозиция предметных доменов (`services/`):**
- [x] **Шаг 2. Декомпозиция предметных доменов (`services/`)** - Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`).
- [x] Сервис задач (`services/tasks/`): CRUD, валидация, markdown-экспорт (`exporter.py`). - [x] **Рефакторинг ETL-конвейера (`services/scud_etl/`):**
- [x] Сервис системного промпта (`services/prompts/`): сборка узлов, вычисление diff (`diff_engine.py`), пакетное удаление (`BATCH_DELETE`). - Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`.
- [x] Сервис снапшотов СКУД (`services/snapshots/`): безопасное удаление с защитой Y-снапшотов. - [x] **Модульный ИИ-оркестратор (`modules/ai_engine/`):**
- [x] Сервис базы знаний (`services/knowledge/`): правила арбитража и синонимы отделов. - Динамический строитель системного контекста (`context_builder.py`).
- [x] **Шаг 3. Рефакторинг ETL-конвейера (`services/scud_etl/`)** - Изолированные обработчики инструментов в `handlers/`.
- [x] Оркестратор этапов контроллинга (`pipeline.py`).
- [x] Детектор аномалий и конфликтов реестров (`anomaly_detector.py`).
- [x] Агрегация проходов и наложение исключений (`merger.py`).
- [x] Информативное консольное логирование этапов в `main_etl.py`.
- [x] **Шаг 4. Модульный ИИ-оркестратор (`modules/ai_engine/`)**
- [x] Динамический строитель системного контекста (`context_builder.py`).
- [x] Изолированные обработчики инструментов (`handlers/task_handler.py`, `handlers/prompt_handler.py`, `handlers/snapshot_handler.py`).
- [x] Компактный диспетчер диалога (`agent.py`).
- [x] Двухконтурный семантический анализатор намерений (`tool_injector.py`).
- [x] **Шаг 5. Зачистка рудиментов и стабилизация**
- [x] Устранение дубликатов в `web_api/llm/db/`.
- [x] Очистка роутеров от прямых манипуляций с базой данных.
- [x] Исправление сигнатур Fast-Path перехватчиков.
--- ---
## 📊 Раздел 9. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]` ## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и автозакрытие 8.5ч `[ЗАВЕРШЕНО]`
- [x] **Реестр исключений в SQLite (`exceptions_registry`):**
- Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД.
- Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`.
- [x] **Агрегация мульти-пропусков физлиц в СКУД:**
- Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие).
- Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`).
- [x] **Умный выбор ставки совместителей 1С:ЗУП:**
- Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД.
- [x] **Автозакрытие смен по Правилу 8.5ч:**
- Для забывших отметиться на выходе офисных сотрудников: автоматический расчет `Вход + 8ч 30мин`, норма 8 часов и отклонение `0:00` в `excel_exporter.py`.
- [x] **Кэш сопоставлений личностей (`person_identity_mapping`):**
- Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`.
- Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`.
- [x] **Каскадный fallback кадровых отсутствий:**
- `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`.
- [ ] **Интерактивный запуск отчетов из чата** ---
- [ ] Инструмент `db_generate_report(date_str, report_type)` для генерации сводки или детального отчета по требованию оператора.
## 10. Разграничение Сводки и Отчета + Почасовые срезы и Y-23:59:59 `[В РАБОТЕ]`
- [ ] **Корректировка финишного среза Y на 23:59:59:**
- [ ] Обновить время вечернего среза с `22:00:00` на `23:59:59` в `services/scud_export.py`, `scud_repo.py` и `pipeline.py`.
- [ ] **Разделение генераторов отчетов:**
- [ ] Создать `services/scud_etl/svodka_generator.py` (оперативная сводка, последний доступный срез или выбранный дневной).
- [ ] Создать `services/scud_etl/otchet_generator.py` (детальный отчет за прошлую смену, строго по единственному итоговому `Y`-снапшоту за 23:59:59).
- [ ] Исключить устаревший `report_generator.py`.
- [ ] **Параметризация `main_etl.py`:**
- [ ] Добавить поддержку аргументов `--date ДД.ММ.ГГГГ` и `--snapshot ID` для свободного запуска расчета за любой прошлый день.
- [ ] **Политика почасовых срезов (Hourly Snapshots) и retention-очистка:**
- [ ] Реализовать фоновое снятие часовых срезов в течение рабочего дня.
- [ ] Настроить ночную очистку промежуточных дневных срезов с сохранением полуденного (13:00) и финишного (`Y` 23:59:59).
- [ ] **Толерантный поиск срезов (Smart Snap-to-Grid, ±15–30 мин):**
- [ ] Поиск готового снапшота в SQLite при запросе оператора с возможностью мгновенного построения сводки без лишних запросов к MS SQL Орион.
- [ ] Формирование On-Demand запроса в MS SQL Орион при отсутствии подходящего среза.
---
## 11. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]`
- [ ] **Интерактивный запуск отчетов из чата:**
- [ ] Инструменты `db_generate_svodka(date_str, snapshot_id)` и `db_generate_otchet(date_str)` в Function Calling.
- [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`). - [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`).
- [ ] **Диалоговый аудит расхождений** - [ ] **Диалоговый аудит расхождений и подтверждение связок:**
- [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера». - [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера».
- [ ] Быстрое внесение исключений и синонимов подразделений через диалог. - [ ] Интерактивное подтверждение предложенных ИИ связок ФИО прямо из чата с записью в `person_identity_mapping`.
- [ ] **Веб-интерфейс управления исключениями:**
- [ ] Веб-форма просмотра и редактирования таблицы `exceptions_registry` в боковом меню.
--- ---
## 10. Изолированная песочница кода (Code Execution Sandbox Engine) ## 12. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
- [ ] **Docker/gVisor контур:** - [ ] **Docker/gVisor контур:**
- Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4]. - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 3].
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4]. - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 3].
- [ ] **Динамические Python/Pandas вычисления:** - [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4]. - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 3].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4]. - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 3].
+12 -5
View File
@@ -1,7 +1,6 @@
""" """
=============================================================================== ===============================================================================
FILE: main_etl.py FILE: main_etl.py
PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП. ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
=============================================================================== ===============================================================================
""" """
@@ -20,7 +19,7 @@ from services.text_reporter import generate_markdown_report
from services.scud_export import run_export from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share from services.share_copier import copy_1c_files_from_share
from services.excel_exporter import generate_summary_excel, generate_detailed_excel from services.excel_exporter import generate_summary_excel, generate_detailed_excel, export_raw_scud
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
@@ -38,7 +37,7 @@ def main():
now = datetime.now() now = datetime.now()
today_str = now.strftime("%d.%m.%Y") today_str = now.strftime("%d.%m.%Y")
# ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня)
if now.weekday() == 0: if now.weekday() == 0:
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
else: else:
@@ -47,7 +46,7 @@ def main():
# [Этап 0] Выгрузка свежих данных СКУД # [Этап 0] Выгрузка свежих данных СКУД
if not args.skip_export and not args.snapshot: if not args.skip_export and not args.snapshot:
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...") print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
run_export(debug=args.debug) run_export(debug=args.debug, save_xlsx=True)
else: else:
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...") print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
@@ -66,6 +65,10 @@ def main():
df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str) df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
df_staff_today, df_abs_today = load_1c_files_for_date(today_str) df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
# Сохранение диагностического дампа сырого СКУД
if df_scud_today is not None and not df_scud_today.empty:
export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx")
# [Этап 3] Детальный отчет за вчера # [Этап 3] Детальный отчет за вчера
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday) df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
@@ -75,7 +78,7 @@ def main():
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...")
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
metrics_today = calculate_summary_metrics(df_merged_today) metrics_today = calculate_summary_metrics(df_merged_today)
anomalies_today = detect_registry_anomalies(df_merged_today) anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today)
generate_summary_excel(df_merged_today, date_str=today_str) generate_summary_excel(df_merged_today, date_str=today_str)
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора # [Этап 5] Формирование Markdown-сводки через ИИ-аудитора
@@ -92,12 +95,16 @@ def main():
(df_merged_today.get('is_excluded', False) == False) (df_merged_today.get('is_excluded', False) == False)
] ]
# ⭐️ Передаем сырые датасеты для выявления опечаток и анализа мульти-записей
summary_md = generate_markdown_report( summary_md = generate_markdown_report(
merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False], merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False],
absent_explained=absent_explained, absent_explained=absent_explained,
absent_unexplained=absent_unexplained, absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=pd.DataFrame(), scud_present_but_absent_in_1c=pd.DataFrame(),
anomalies_list=anomalies_today, anomalies_list=anomalies_today,
raw_scud_df=df_scud_today,
raw_staff_df=df_staff_today,
raw_absent_df=df_abs_today,
date_str=today_str date_str=today_str
) )
+38
View File
@@ -24,6 +24,44 @@ from services.exceptions_repo import (
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def cmd_mapping(args_list):
"""Управление подтвержденными сопоставлениями ФИО (СКУД <-> 1С:ЗУП)."""
with get_connection() as conn:
cursor = conn.cursor()
if not args_list or args_list[0] in ["list", "show"]:
cursor.execute("SELECT id, scud_fio, zup_fio, match_source, status FROM person_identity_mapping ORDER BY id DESC")
rows = cursor.fetchall()
print("\n🔗 СОХРАНЕННЫЕ СОПОСТАВЛЕНИЯ ФИО (person_identity_mapping):")
print("=" * 80)
if rows:
for r in rows:
print(f" #{r[0]} [{r[4]}] СКУД: '{r[1]}' ⟷ 1С: '{r[2]}' ({r[3]})")
else:
print(" — сопоставлений пока нет")
print("=" * 80 + "\n")
return
subcmd = args_list[0]
if subcmd == "add":
if len(args_list) < 3:
print("Использование: python scripts/db_cli.py mapping add 'ФИО в СКУД' 'ФИО в 1С'")
return
scud_f, zup_f = args_list[1], args_list[2]
cursor.execute("""
INSERT OR REPLACE INTO person_identity_mapping (scud_fio, zup_fio, match_source, status)
VALUES (?, ?, 'MANUAL', 'ACTIVE')
""", (scud_f.strip(), zup_f.strip()))
conn.commit()
print(f"✅ Успешно добавлена связка: '{scud_f}' ⟷ '{zup_f}'")
elif subcmd in ["del", "delete", "remove"]:
if len(args_list) < 2:
print("Использование: python scripts/db_cli.py mapping del 'ФИО в СКУД'")
return
cursor.execute("DELETE FROM person_identity_mapping WHERE scud_fio = ?", (args_list[1].strip(),))
conn.commit()
print(f"✅ Связка для '{args_list[1]}' удалена.")
def print_tool_actions(): def print_tool_actions():
"""Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" """Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
+52 -61
View File
@@ -3,7 +3,7 @@ import os
import time import time
import openpyxl import openpyxl
import pandas as pd import pandas as pd
from datetime import datetime from datetime import datetime, timedelta
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -23,16 +23,18 @@ MONTHS_RU_NOMINATIVE = {
def format_date_ru(date_str): def format_date_ru(date_str):
date_clean = str(date_str).replace('_', '.')
try: try:
dt = datetime.strptime(date_str, "%d.%m.%Y") dt = datetime.strptime(date_clean, "%d.%m.%Y")
return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}" return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}"
except Exception: except Exception:
return date_str return date_str
def get_dated_reports_dir(date_str): def get_dated_reports_dir(date_str):
date_clean = str(date_str).replace('_', '.')
try: try:
dt = datetime.strptime(date_str, "%d.%m.%Y") dt = datetime.strptime(date_clean, "%d.%m.%Y")
year_str = str(dt.year) year_str = str(dt.year)
month_name = MONTHS_RU_NOMINATIVE[dt.month] month_name = MONTHS_RU_NOMINATIVE[dt.month]
except Exception: except Exception:
@@ -55,14 +57,30 @@ FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="
FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid") FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid")
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", 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") FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid")
FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") # Мягкий бежево-серый для исключений FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid")
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"] CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"]
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") 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_autoclose_time(time_in_str: str) -> tuple[str, str, str]:
"""
⭐️ Правило 8.5ч: при наличии входа и отсутствии выхода
рассчитывает время выхода (Вход + 8ч 30мин) с нормой 8:00 и отклонением 0:00.
"""
try:
parts = time_in_str.strip().split(':')
hh = int(parts[0])
mm = int(parts[1]) if len(parts) > 1 else 0
ss = int(parts[2]) if len(parts) > 2 else 0
dt_in = datetime(2000, 1, 1, hh, mm, ss)
dt_out = dt_in + timedelta(hours=8, minutes=30)
return dt_out.strftime("%H:%M:%S"), "08:30", "0:00"
except Exception:
return "17:00:00", "08:30", "0:00"
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
@@ -119,10 +137,11 @@ def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_f
# --- 1. СВОДКА НА СЕГОДНЯ --- # --- 1. СВОДКА НА СЕГОДНЯ ---
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
date_clean = str(date_str).replace('_', '.')
if not filename: if not filename:
filename = f"{format_date_ru(date_str)} сводка.xlsx" filename = f"{format_date_ru(date_clean)} сводка.xlsx"
target_dir = get_dated_reports_dir(date_str) target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename) output_path = os.path.join(target_dir, filename)
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
@@ -135,15 +154,13 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
bold_font = Font(name="Calibri", size=11, bold=True) bold_font = Font(name="Calibri", size=11, bold=True)
# 1. Шапка
ws.cell(row=1, column=1, value="Сводка на") ws.cell(row=1, column=1, value="Сводка на")
ws.cell(row=1, column=2, value=date_str) ws.cell(row=1, column=2, value=date_clean)
format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font)
apply_borders_to_cell(ws.cell(row=2, column=1)) apply_borders_to_cell(ws.cell(row=2, column=1))
apply_borders_to_cell(ws.cell(row=2, column=2)) apply_borders_to_cell(ws.cell(row=2, column=2))
# 2. По списку
ws.cell(row=3, column=1, value="По списку") ws.cell(row=3, column=1, value="По списку")
ws.cell(row=3, column=2, value=len(merged_df)) ws.cell(row=3, column=2, value=len(merged_df))
format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font) format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font)
@@ -153,7 +170,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False 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 is_exc = merged_df.get('is_excluded', False) == True
# 3. НЕИЗВЕСТНО
unexplained = merged_df[ unexplained = merged_df[
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
@@ -172,9 +188,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = False ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() 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=1, value="Нет пропуска")
ws.cell(row=current_row, column=2, value=len(no_pass_df)) 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) format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font)
@@ -188,7 +202,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = False ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (без удаленки и исключений)
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
@@ -196,7 +209,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason) & (~is_remote_reason) &
(~is_exc) (~is_exc)
] ]
absent_groups = absent_only.groupby('Вид_отсутствия') absent_groups = absent_only.groupby('Вид_отсутствия')
@@ -217,9 +230,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 6. ИТОГО НА РАБОТЕ (Строго физически пришедшие в офис по СКУД)
present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
ws.cell(row=current_row, column=1, value="Итого на работе") ws.cell(row=current_row, column=1, value="Итого на работе")
ws.cell(row=current_row, column=2, value=len(present)) ws.cell(row=current_row, column=2, value=len(present))
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
@@ -233,9 +244,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 6.1. В ТОМ ЧИСЛЕ НА УДАЛЕННОЙ РАБОТЕ (Удаленщики из дома, которых нет в офисе)
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
ws.cell(row=current_row, column=1, value="В том числе на удаленной работе") ws.cell(row=current_row, column=1, value="В том числе на удаленной работе")
ws.cell(row=current_row, column=2, value=len(remote_home)) ws.cell(row=current_row, column=2, value=len(remote_home))
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font)
@@ -249,7 +258,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 7. АНОМАЛИИ СКУД И 1С
anomalies = merged_df[ anomalies = merged_df[
(~is_exc) & ( (~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
@@ -266,7 +274,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
current_row += 1 current_row += 1
chars_per_line_b = 30 chars_per_line_b = 30
if not anomalies.empty: if not anomalies.empty:
for _, row in anomalies.iterrows(): for _, row in anomalies.iterrows():
fio = row.get('Сотрудник', '') fio = row.get('Сотрудник', '')
@@ -281,28 +288,20 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}") cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text) cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY cell_a.fill = FILL_ANOMALY
cell_b.fill = FILL_ANOMALY cell_b.fill = FILL_ANOMALY
apply_borders_to_cell(cell_a) apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b) apply_borders_to_cell(cell_b)
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center") cell_a.alignment = Alignment(horizontal="left", vertical="center")
if len(reason_text) > chars_per_line_b: lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
lines_count = math.ceil(len(reason_text) / chars_per_line_b) ws.row_dimensions[current_row].height = max(lines_count * 18, 20)
ws.row_dimensions[current_row].height = max(lines_count * 18, 22)
else:
ws.row_dimensions[current_row].height = 20
ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# ⭐️ 8. РАЗДЕЛ: ИСКЛЮЧЕНИЯ (В самом низу, раскрывающийся список)
exceptions_df = merged_df[is_exc] exceptions_df = merged_df[is_exc]
ws.cell(row=current_row, column=1, value="Исключения") ws.cell(row=current_row, column=1, value="Исключения")
ws.cell(row=current_row, column=2, value=len(exceptions_df)) ws.cell(row=current_row, column=2, value=len(exceptions_df))
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font)
@@ -322,31 +321,32 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
try: try:
wb.save(output_path) wb.save(output_path)
print(f"[✓] Ежедневная сводка сохранена: {output_path}") print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except PermissionError: except (PermissionError, OSError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename) alt_path = os.path.join(target_dir, alt_filename)
wb.save(alt_path) wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- # --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ПРАВИЛОМ 8.5ч) ---
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
date_clean = str(date_str).replace('_', '.')
if not filename: if not filename:
filename = f"{format_date_ru(date_str)} отчет.xlsx" filename = f"{format_date_ru(date_clean)} отчет.xlsx"
if merged_df is not None and not merged_df.empty: if merged_df is not None and not merged_df.empty:
df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() df_export = merged_df[merged_df.get('is_excluded', False) == False].copy()
else: else:
df_export = pd.DataFrame() df_export = pd.DataFrame()
target_dir = get_dated_reports_dir(date_str) target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename) output_path = os.path.join(target_dir, filename)
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "Детальный_отчет" ws.title = "Детальный_отчет"
ws["B2"] = "Дата:" ws["B2"] = "Дата:"
ws["D2"] = date_str ws["D2"] = date_clean
ws["B2"].font = Font(name="Arial", size=10, bold=True) ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True) ws["D2"].font = Font(name="Arial", size=10, bold=True)
@@ -375,22 +375,28 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
is_present = row.get('Пришел', False) is_present = row.get('Пришел', False)
absence_reason = row.get('Вид_отсутствия', '') absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
anom_flag = row.get('anomaly_flag', 'NONE')
in_val = str(row.get(start_col, 'Нет входа')).strip()
out_val = str(row.get(end_col, 'Нет выхода')).strip()
in_building_str = str(row.get(hours_col, '00:00')) in_building_str = str(row.get(hours_col, '00:00'))
first_act_val = str(row.get('Первая_активность', '—')).strip() first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan'] has_first_act = first_act_val not in ['—', '', 'None', 'nan']
# ⭐️ ПРАВИЛО 8.5ч: если есть вход, но нет выхода (и нет официального документа отсутствия)
if in_val not in ['Нет входа', '—', '', 'nan', 'None'] and out_val in ['Нет выхода', '—', '', 'nan', 'None'] and not has_reason:
out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val)
else:
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) 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([ ws.append([
idx + 1, idx + 1,
row.get('Сотрудник', ''), row.get('Сотрудник', ''),
dept_scud_val, dept_scud_val,
row.get(start_col, 'Нет входа'), in_val,
first_act_val, first_act_val,
row.get(end_col, 'Нет выхода'), out_val,
in_building_str, in_building_str,
absence_reason if has_reason else '', absence_reason if has_reason else '',
8, 8,
@@ -398,7 +404,6 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
]) ])
row_num = 5 + idx row_num = 5 + idx
if is_present and has_reason: if is_present and has_reason:
row_fill = GREEN_FILL row_fill = GREEN_FILL
elif not is_present and has_reason: elif not is_present and has_reason:
@@ -409,18 +414,14 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
row_fill = None row_fill = None
val_h_str = str(absence_reason) if has_reason else "" val_h_str = str(absence_reason) if has_reason else ""
if len(val_h_str) > chars_per_line_h: lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1
needed_lines = math.ceil(len(val_h_str) / chars_per_line_h) ws.row_dimensions[row_num].height = max(lines_count * 18, 20)
ws.row_dimensions[row_num].height = max(needed_lines * 18, 22)
else:
ws.row_dimensions[row_num].height = 20
for col_idx in range(1, len(headers) + 1): for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=row_num, column=col_idx) cell = ws.cell(row=row_num, column=col_idx)
apply_borders_to_cell(cell) apply_borders_to_cell(cell)
if row_fill: if row_fill:
cell.fill = row_fill cell.fill = row_fill
if col_idx == 8: if col_idx == 8:
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
elif col_idx in [1, 4, 5, 6, 7, 9, 10]: elif col_idx in [1, 4, 5, 6, 7, 9, 10]:
@@ -430,23 +431,13 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
for col in ws.columns: for col in ws.columns:
col_letter = get_column_letter(col[0].column) col_letter = get_column_letter(col[0].column)
max_len = 0 max_len = max((len(str(cell.value or '')) for cell in col), default=8)
for cell in col: ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 35)
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: try:
wb.save(output_path) wb.save(output_path)
print(f"[✓] Детальный отчет сохранен: {output_path}") print(f"[✓] Детальный отчет сохранен: {output_path}")
except PermissionError: except (PermissionError, OSError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename) alt_path = os.path.join(target_dir, alt_filename)
wb.save(alt_path) wb.save(alt_path)
+27 -16
View File
@@ -1,32 +1,44 @@
""" """
=============================================================================== ===============================================================================
FILE: services/scud_etl/anomaly_detector.py FILE: services/scud_etl/anomaly_detector.py
PROJECT: SCUD Orion AI (Unified Architecture) ROLE: Детектирование аномалий СКУД, дубликатов пропусков и несоответствий с 1С.
MODULE: services / scud_etl
ROLE: Детектирование истинных аномалий и конфликтов реестров.
(Сотрудники из exceptions.json, удаленка и командировки исключены).
=============================================================================== ===============================================================================
""" """
import logging import logging
from typing import List, Dict, Any from typing import List, Dict, Any
import pandas as pd import pandas as pd
from config import normalize_fio
logger = logging.getLogger("SCUD_ANOMALY") logger = logging.getLogger("SCUD_ANOMALY")
def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]: def detect_registry_anomalies(df_merged: pd.DataFrame, df_raw_scud: pd.DataFrame = None) -> List[Dict[str, Any]]:
"""
Выявляет реальные аномалии:
- Приход в офис во время отпуска или больничного листа.
- Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа).
"""
anomalies = [] anomalies = []
if df_merged is None or df_merged.empty: if df_merged is None or df_merged.empty:
return anomalies return anomalies
# 1. ⭐️ Поиск дубликатов учеток/пропусков в сыром СКУД
if df_raw_scud is not None and not df_raw_scud.empty:
df_raw = df_raw_scud.copy()
if 'fio_clean' not in df_raw.columns:
f_col = 'Сотрудник' if 'Сотрудник' in df_raw.columns else 'ФИО'
df_raw['fio_clean'] = df_raw[f_col].apply(normalize_fio)
counts = df_raw['fio_clean'].value_counts()
duplicate_fios = counts[counts > 1].index.tolist()
for dup_fio in duplicate_fios:
sub = df_raw[df_raw['fio_clean'] == dup_fio]
departments = ", ".join(sub['Подразделение'].astype(str).unique())
anomalies.append({
"fio": dup_fio,
"type": "DUPLICATE_SCUD_CARD",
"description": f"Сотрудник заведен в СКУД {len(sub)} раза (отделы: {departments}). События входа и выхода объединены автоматически."
})
# 2. Поиск кадровых аномалий и сбоев оборудования
for _, row in df_merged.iterrows(): for _, row in df_merged.iterrows():
# Пропускаем системные исключения
if row.get("is_excluded", False): if row.get("is_excluded", False):
continue continue
@@ -36,25 +48,24 @@ def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip() reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip()
reason_lower = reason.lower() reason_lower = reason.lower()
# Пропускаем технические метки
if "исключен" in reason_lower or "овк" in reason_lower: if "исключен" in reason_lower or "овк" in reason_lower:
continue continue
# ⭐️ Физический приход в офис: удаленка и командировки разрешены и НЕ являются аномалией # Приход в офис во время отпуска/больничного (удаленка и командировки разрешены)
if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan": if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan":
if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower): if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
anomalies.append({ anomalies.append({
"fio": fio, "fio": fio,
"type": "PHYSICAL_PRESENCE_DURING_ABSENCE", "type": "PHYSICAL_PRESENCE_DURING_ABSENCE",
"description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'." "description": f"Присутствовал в здании ({start_day}), но в 1С числится документ: '{reason}'."
}) })
# Аномалия оборудования (есть выход без утреннего входа) # Ошибка считывателя (есть выход без входа)
if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]: if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]:
anomalies.append({ anomalies.append({
"fio": fio, "fio": fio,
"type": "SCUD_EQUIPMENT_ANOMALY", "type": "SCUD_EQUIPMENT_ANOMALY",
"description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})." "description": f"Зафиксирован выход ({end_day}) при отсутствии отметки утреннего входа."
}) })
return anomalies return anomalies
+129 -14
View File
@@ -1,19 +1,141 @@
""" """
=============================================================================== ===============================================================================
FILE: services/scud_etl/merger.py FILE: services/scud_etl/merger.py
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки.
=============================================================================== ===============================================================================
""" """
import logging import logging
from typing import Dict, Any from typing import Dict, Any, List
import pandas as pd import pandas as pd
from services.knowledge.service import get_department_synonyms_dict from services.knowledge.service import get_department_synonyms_dict
from config import normalize_fio, load_exceptions from config import normalize_fio, load_exceptions
from core.connection import get_connection
logger = logging.getLogger("SCUD_MERGER") logger = logging.getLogger("SCUD_MERGER")
def load_identity_mappings() -> Dict[str, str]:
"""Загружает подтвержденные сопоставления ФИО (СКУД -> 1С:ЗУП) из SQLite."""
with get_connection() as conn:
cursor = conn.cursor()
# Автоматическая инициализация таблицы при первом обращении
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI',
status TEXT DEFAULT 'ACTIVE',
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
cursor.execute("""
SELECT scud_fio, zup_fio
FROM person_identity_mapping
WHERE status = 'ACTIVE'
""")
return {r[0]: r[1] for r in cursor.fetchall()}
def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
"""Схлопывает дубликаты пропусков одного человека в СКУД."""
if df_scud is None or df_scud.empty:
return df_scud
df = df_scud.copy()
if 'fio_clean' not in df.columns:
fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО'
df['fio_clean'] = df[fio_col].apply(normalize_fio)
# Применяем сохраненный кэш связок ФИО
mapping_dict = load_identity_mappings()
if mapping_dict:
df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f))
aggregated_rows = []
for fio_clean, group in df.groupby('fio_clean'):
if len(group) == 1:
aggregated_rows.append(group.iloc[0].to_dict())
continue
base_row = group.sort_values(by='Пришел', ascending=False).iloc[0].to_dict()
valid_ins = [
str(t).strip() for t in group['Начало_дня']
if str(t).strip() not in ['Нет входа', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Начало_дня'] = min(valid_ins) if valid_ins else 'Нет входа'
valid_outs = [
str(t).strip() for t in group['Конец_дня']
if str(t).strip() not in ['Нет выхода', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Конец_дня'] = max(valid_outs) if valid_outs else 'Нет выхода'
valid_acts = [
str(t).strip() for t in group['Первая_активность']
if str(t).strip() not in ['—', '', 'nan', 'None', '00:00:00']
]
base_row['Первая_активность'] = min(valid_acts) if valid_acts else '—'
base_row['Пришел'] = any(group['Пришел'] == True) or (base_row['Начало_дня'] != 'Нет входа')
durations = [str(d) for d in group['Находился_в_здании'] if str(d) not in ['00:00', '', 'nan']]
if durations:
base_row['Находился_в_здании'] = max(durations)
aggregated_rows.append(base_row)
return pd.DataFrame(aggregated_rows)
def select_best_zup_position(df_staff_1c: pd.DataFrame, df_scud_agg: pd.DataFrame) -> pd.DataFrame:
"""
⭐️ Для совместителей с несколькими должностями в 1С:ЗУП
выбирает ту ставку, которая соответствует отделу физического нахождения по СКУД.
"""
if df_staff_1c is None or df_staff_1c.empty:
return pd.DataFrame()
df_staff = df_staff_1c.copy()
if 'fio_clean' not in df_staff.columns:
f_col = 'ФИО' if 'ФИО' in df_staff.columns else 'Сотрудник'
df_staff['fio_clean'] = df_staff[f_col].apply(normalize_fio)
scud_dept_map = {}
if df_scud_agg is not None and not df_scud_agg.empty:
for _, r in df_scud_agg.iterrows():
scud_dept_map[r.get('fio_clean', '')] = str(r.get('Подразделение', '')).strip().upper()
best_rows = []
for fio, group in df_staff.groupby('fio_clean'):
if len(group) == 1:
best_rows.append(group.iloc[0].to_dict())
continue
target_scud_dept = scud_dept_map.get(fio, "")
matched_row = None
if target_scud_dept:
for _, r in group.iterrows():
dept_1c = str(r.get('Подразделение', '')).strip().upper()
if dept_1c == target_scud_dept or target_scud_dept in dept_1c or dept_1c in target_scud_dept:
matched_row = r.to_dict()
break
if not matched_row:
matched_row = group.iloc[0].to_dict()
best_rows.append(matched_row)
return pd.DataFrame(best_rows)
def merge_scud_and_1c( def merge_scud_and_1c(
df_scud: pd.DataFrame, df_scud: pd.DataFrame,
df_staff_1c: pd.DataFrame, df_staff_1c: pd.DataFrame,
@@ -29,7 +151,10 @@ def merge_scud_and_1c(
synonyms = get_department_synonyms_dict() synonyms = get_department_synonyms_dict()
exceptions_cfg = load_exceptions() exceptions_cfg = load_exceptions()
df_res = df_scud.copy() if df_scud is not None and not df_scud.empty else df_staff_1c.copy() df_scud_agg = aggregate_scud_by_person(df_scud)
df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg)
df_res = df_scud_agg.copy() if df_scud_agg is not None and not df_scud_agg.empty else df_staff_agg.copy()
if "Сотрудник" in df_res.columns: if "Сотрудник" in df_res.columns:
df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio) df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio)
@@ -50,7 +175,6 @@ def merge_scud_and_1c(
if col not in df_res.columns: if col not in df_res.columns:
df_res[col] = default_val df_res[col] = default_val
# 1. Приведение отделов к аббревиатурам
reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()} reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()}
direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()} direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()}
all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"} all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"}
@@ -60,9 +184,8 @@ def merge_scud_and_1c(
lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip())
) )
# 2. Привязка документов отсутствий из 1С ПЕРЕД разметкой исключений
absences_map = {} absences_map = {}
if not df_absences_1c.empty: if df_absences_1c is not None and not df_absences_1c.empty:
fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None) fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None)
reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None) reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None)
@@ -76,7 +199,6 @@ def merge_scud_and_1c(
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
df_res["Вид_отсутствия"] = df_res["причина отсутствия"] df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
# 3. Разметка исключений
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d] exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d]
exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
@@ -86,9 +208,8 @@ def merge_scud_and_1c(
df_res["is_excluded"] = False df_res["is_excluded"] = False
for idx, row in df_res.iterrows(): for idx, row in df_res.iterrows():
fio = row.get("fio_clean", "") fio = row.get("fio_clean", "")
has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() != "" has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() not in ["", "nan", "None", "Исключение"]
# ⭐️ Если сотрудник в белом списке ИЛИ у него есть официальное отсутствие — он НЕ исключается
if fio in whitelist_fios or has_official_absence: if fio in whitelist_fios or has_official_absence:
df_res.at[idx, "is_excluded"] = False df_res.at[idx, "is_excluded"] = False
continue continue
@@ -99,7 +220,6 @@ def merge_scud_and_1c(
if fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw): if fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw):
df_res.at[idx, "is_excluded"] = True df_res.at[idx, "is_excluded"] = True
# Чистое "Исключение" только для истинно исключенных без документов 1С
mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == "")) mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == ""))
df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение" df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение"
df_res.loc[mask_exc, "причина отсутствия"] = "Исключение" df_res.loc[mask_exc, "причина отсутствия"] = "Исключение"
@@ -111,21 +231,17 @@ def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
active_df = df_merged[df_merged.get("is_excluded", False) == False].copy() active_df = df_merged[df_merged.get("is_excluded", False) == False].copy()
total_staff = len(active_df) total_staff = len(active_df)
# 1. Строго физически пришедшие в офис
came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа") came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа")
working_in_office = active_df[came_to_office_mask] working_in_office = active_df[came_to_office_mask]
working_in_office_count = len(working_in_office) working_in_office_count = len(working_in_office)
# 2. Не пришедшие в офис
df_not_came = active_df[~came_to_office_mask] df_not_came = active_df[~came_to_office_mask]
# 3. Из не пришедших — удаленщики (работают из дома)
reason_series = df_not_came["причина отсутствия"].astype(str).str.lower() reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home = df_not_came[is_remote_mask] remote_home = df_not_came[is_remote_mask]
remote_home_count = len(remote_home) remote_home_count = len(remote_home)
# 4. Из оставшихся — официальные отсутствия (отпуск, больничный, командировка)
df_remaining_absent = df_not_came[~is_remote_mask] df_remaining_absent = df_not_came[~is_remote_mask]
has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \
df_remaining_absent["причина отсутствия"].ne("") & \ df_remaining_absent["причина отсутствия"].ne("") & \
@@ -133,7 +249,6 @@ def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
(~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение")) (~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение"))
official_absent_count = len(df_remaining_absent[has_doc_mask]) official_absent_count = len(df_remaining_absent[has_doc_mask])
# 5. Неизвестные
unknown = df_remaining_absent[~has_doc_mask] unknown = df_remaining_absent[~has_doc_mask]
unknown_count = len(unknown) unknown_count = len(unknown)
+36 -48
View File
@@ -5,44 +5,36 @@ from services.ai_verifier import ask_ollama
from services.knowledge_base import load_knowledge_base from services.knowledge_base import load_knowledge_base
def find_python_fuzzy_matches(unexplained_df, raw_absent_df): def find_ai_identity_suggestions(unexplained_df, raw_staff_df):
if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: """
⭐️ Теневой ИИ-арбитраж: ищет потенциальные опечатки операторов СКУД
среди нераспознанных сотрудников и формирует рекомендации для администратора.
"""
if unexplained_df.empty or raw_staff_df is None or raw_staff_df.empty:
return [] return []
absent_dict = {} staff_fios = raw_staff_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in raw_staff_df.columns else []
for idx, r in raw_absent_df.iterrows(): unexp_fios = unexplained_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in unexplained_df.columns else []
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 = [] suggestions = []
unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else [] for scud_fio in unexp_fios:
f_parts = scud_fio.split()
for fio in unexplained_fios: if not f_parts:
fio_parts = fio.split()
if not fio_parts:
continue continue
surname = fio_parts[0].lower() surname = f_parts[0].lower()
for abs_clean, abs_info in absent_dict.items(): for staff_fio in staff_fios:
abs_parts = abs_clean.split() if staff_fio.lower().startswith(surname[:4]):
if not abs_parts: ratio = difflib.SequenceMatcher(None, scud_fio.lower(), staff_fio.lower()).ratio()
continue if 0.75 <= ratio < 1.0:
abs_surname = abs_parts[0].lower() suggestions.append({
"scud_fio": scud_fio,
if surname == abs_surname: "suggested_zup_fio": staff_fio,
ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio() "confidence": round(ratio, 2)
if ratio >= 0.75:
matches.append({
"target_fio": fio,
"found_in_1c_raw": abs_info['raw_fio'],
"reason_1c": abs_info['reason']
}) })
break break
return matches return suggestions
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"): 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"):
@@ -51,22 +43,18 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
total_staff = len(merged_df) total_staff = len(merged_df)
# 1. Пришедшие в офис
present_office_cnt = len(merged_df[merged_df['Пришел'] == True]) present_office_cnt = len(merged_df[merged_df['Пришел'] == True])
# 2. Не пришедшие удаленщики
reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower() reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False) is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False)
remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason]) remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason])
# 3. Официально отсутствующие (без удаленки)
explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))]) explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))])
unexplained_cnt = len(absent_unexplained) unexplained_cnt = len(absent_unexplained)
anomalies_cnt = len(anomalies_list) if anomalies_list else 0 anomalies_cnt = len(anomalies_list) if anomalies_list else 0
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) # ⭐️ Теневые подсказки ИИ
ai_suggestions = find_ai_identity_suggestions(absent_unexplained, raw_staff_df)
anomalies_formatted = [] anomalies_formatted = []
if anomalies_list: if anomalies_list:
@@ -106,18 +94,18 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
{anomalies_text_block} {anomalies_text_block}
--- ---
⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт): 💡 ПРЕДЛОЖЕНИЯ ИИ ПО СОПОСТАВЛЕНИЮ ФИО ({len(ai_suggestions)} шт):
{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)} {json.dumps(ai_suggestions, ensure_ascii=False, indent=2)}
--- ---
📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): 📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел):
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)} {json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ: СТРОГИЕ ИНСТРУКЦИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". Если аномалий нет, напиши: "Аномалий не обнаружено." 1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ".
2. В разделе "Подозрения на ошибки сопоставления ФИО" используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если он пуст, напиши: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." 2. В разделе "Подозрения на ошибки сопоставления ФИО" выведи рекомендации по сопоставлению из массива "ПРЕДЛОЖЕНИЯ ИИ". Если массив пуст — напиши "Ошибок сопоставления ФИО не обнаружено."
3. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. 3. В разделе "Неизвестные случаи" выведи нумерованный список всех {unexplained_cnt} человек.
4. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы без перечисления конкретных ФИО. 4. В разделе "Рекомендации" дай 2-3 системные рекомендации.
СТРОГИЙ ШАБЛОН ОТВЕТА: СТРОГИЙ ШАБЛОН ОТВЕТА:
@@ -131,17 +119,17 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. - Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): #### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
(Выведи нумерованный текстовый список аномалий или 'Аномалий не обнаружено.') (Список аномалий или 'Аномалий не обнаружено.')
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: #### 💡 Подозрения на ошибки сопоставления ФИО и предложения ИИ:
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива ошибок ФИО. Если пуст — 'Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.') (Список предложений сопоставления или 'Ошибок сопоставления ФИО не обнаружено.')
#### Неизвестные случаи: {unexplained_cnt} #### Неизвестные случаи: {unexplained_cnt}
(Выведи нумерованный список всех {unexplained_cnt} человек) (Нумерованный список всех {unexplained_cnt} человек)
### Точечные рекомендации: ### Точечные рекомендации:
(2-3 системные рекомендации) (2-3 системные рекомендации)
""" """
sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке обычным форматированным текстом. Категорически запрещено выводить фигурные скобки JSON или технический код." sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке форматированным текстом. Запрещено выводить фигурные скобки JSON."
return ask_ollama(prompt, system_prompt=sys_prompt) return ask_ollama(prompt, system_prompt=sys_prompt)