2 Commits
18 changed files with 784 additions and 308 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ def has_yesterday_final_snapshot(date_str: str) -> bool:
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1", "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
(date_str,) (date_str,)
) )
return cursor.fetchone() is not None return cursor.fetchone() is not None
+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.
Binary file not shown.
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`) с кнопками быстрого действия.
+100 -76
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,74 @@
--- ---
## 🏗️ Раздел 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` в БД.
- [ ] Инструмент `db_generate_report(date_str, report_type)` для генерации сводки или детального отчета по требованию оператора. - Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`.
- [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`). - [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`.
--- ---
## 10. Изолированная песочница кода (Code Execution Sandbox Engine) ## 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. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]`
- [ ] **Двухконтурный запуск ИИ:**
- [ ] Перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`.
- [ ] **Якорный табельный номер (TabNo Matching):**
- [ ] Извлечение `TabNo` из MS SQL Орион (`pList.TabNo`) и MS SQL 1С:ЗУП.
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite (`scud_logs`, `zup_staff`, `person_identity_mapping`).
- [ ] Защита от смены фамилий и опечаток через неизменяемый табельный номер.
- [ ] **Динамическая инвалидация кэша связок:**
- [ ] Автоматическая отбраковка записей кэша при увольнении сотрудника или несовпадении подразделения.
---
## 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].
+42 -18
View File
@@ -16,11 +16,13 @@ from datetime import datetime, timedelta
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.scud_etl.svodka_generator import generate_svodka_service
from services.scud_etl.otchet_generator import generate_otchet_service
from services.text_reporter import generate_markdown_report 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 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")
@@ -29,7 +31,9 @@ def main():
parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С") parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С")
parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки") parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки")
parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL") parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL")
parser.add_argument("--snapshot", type=str, default=None, help="ID снапшота для расчета") parser.add_argument("--date", type=str, default=None, help="Дата расчета в формате ДД.ММ.ГГГГ")
parser.add_argument("--time", type=str, default=None, help="Время среза для сводки (например, 14:30)")
parser.add_argument("--snapshot", type=str, default=None, help="Точный ID снапшота для расчета")
args = parser.parse_args() args = parser.parse_args()
print("=" * 60) print("=" * 60)
@@ -37,17 +41,22 @@ def main():
print("=" * 60) print("=" * 60)
now = datetime.now() now = datetime.now()
today_str = now.strftime("%d.%m.%Y") if args.date:
# ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня) today_str = args.date.replace('_', '.')
if now.weekday() == 0: dt_target = datetime.strptime(today_str, "%d.%m.%Y")
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") days_back = 3 if dt_target.weekday() == 0 else 1
yesterday_str = (dt_target - timedelta(days=days_back)).strftime("%d.%m.%Y")
else: else:
yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y") today_str = now.strftime("%d.%m.%Y")
if now.weekday() == 0:
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
else:
yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
# [Этап 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(input_date=args.date, 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,21 +75,33 @@ 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)
# [Этап 3] Детальный отчет за вчера 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] Детальный отчет за вчера через otchet_generator
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) res_otchet = generate_otchet_service(target_date=yesterday_str)
generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str) if res_otchet.get("status") == "success":
print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}")
# [Этап 4] Сводка за сегодня # [Этап 4] Сводка за сегодня через svodka_generator
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...")
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) res_svodka = generate_svodka_service(
metrics_today = calculate_summary_metrics(df_merged_today) target_date=today_str,
anomalies_today = detect_registry_anomalies(df_merged_today) target_time=args.time,
generate_summary_excel(df_merged_today, date_str=today_str) snapshot_id=args.snapshot
)
if res_svodka.get("status") == "success":
print(f"[✓] {res_svodka.get('message')}: {res_svodka.get('filepath')}")
if res_svodka.get("note"):
print(f" ℹ️ {res_svodka.get('note')}")
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора # [Этап 5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...") print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today)
absent_explained = df_merged_today[ absent_explained = df_merged_today[
(df_merged_today['Пришел'] == False) & (df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].notna()) & (df_merged_today['Вид_отсутствия'].notna()) &
@@ -98,6 +119,9 @@ def main():
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)
+59
View File
@@ -0,0 +1,59 @@
"""
===============================================================================
FILE: services/scud_etl/otchet_generator.py
ROLE: Генератор Детального Отчета за прошлые смены (строго по итоговому Y-снапшоту).
===============================================================================
"""
import os
import logging
from typing import Dict, Any, Optional
import pandas as pd
from config import DATE_YESTERDAY
from core.database import load_scud_from_db_by_snapshot
from services.scud_etl.pipeline import load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c
from services.excel_exporter import generate_detailed_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("OTCHET_GENERATOR")
def generate_otchet_service(
target_date: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует детальный отчет за прошедшую смену:
- target_date: дата отчета (по умолчанию вчерашний рабочий день).
- snapshot_id: опциональный ID (по умолчанию выбирается итоговый вечерний срез Y).
"""
date_clean = str(target_date or DATE_YESTERDAY).replace('_', '.')
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Итоговый срез СКУД (Y) за {date_clean} не найден в базе данных."
}
df_staff, df_abs = load_1c_files_for_date(date_clean)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
filename = f"{format_date_ru(date_clean)} отчет.xlsx"
generate_detailed_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "OTCHET",
"date": date_clean,
"snapshot_id": snapshot_id or "AUTO_Y_FINAL",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"total_rows": len(df_merged),
"message": f"Детальный отчет за {date_clean} успешно сформирован."
}
+39 -34
View File
@@ -1,59 +1,64 @@
""" """
=============================================================================== ===============================================================================
FILE: services/scud_etl/pipeline.py FILE: services/scud_etl/pipeline.py
ROLE: Выборка данных СКУД из SQLite (scud_logs) с жестким приоритетом Y-снапшотов.
=============================================================================== ===============================================================================
""" """
import os import os
import logging import logging
from typing import Optional, Dict, Any, Tuple
import pandas as pd import pandas as pd
from core.connection import get_connection from core.connection import get_connection
from services.data_loader import load_1c_data_smart from core.database import load_scud_from_db_by_snapshot
from config import DATA_DIR
from services.data_loader import load_staff_data, load_absent_data
logger = logging.getLogger("SCUD_PIPELINE") logger = logging.getLogger("SCUD_PIPELINE")
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame: def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) -> Optional[pd.DataFrame]:
"""Извлекает срез СКУД. Для вчерашнего дня строго берет финальный вечерний срез Y.""" """
Загружает наилучший срез СКУД за дату.
Если prefer_final_y=True — отдает предпочтение финишному Y (23:59:59).
"""
with get_connection() as conn: with get_connection() as conn:
df = pd.DataFrame()
cursor = conn.cursor() cursor = conn.cursor()
target_snap_id = None
if prefer_final_y: if prefer_final_y:
cursor.execute( cursor.execute("""
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", SELECT snapshot_id
(date_str,) FROM scud_logs
) WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00')
ORDER BY id DESC LIMIT 1
""", (date_str,))
row = cursor.fetchone() row = cursor.fetchone()
if row and row[0]: if row:
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) target_snap_id = row[0]
if df.empty: if not target_snap_id:
cursor.execute( cursor.execute("""
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", SELECT snapshot_id
(date_str,) FROM scud_logs
) WHERE log_date = ?
ORDER BY id DESC LIMIT 1
""", (date_str,))
row = cursor.fetchone() row = cursor.fetchone()
if row and row[0]: if row:
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) target_snap_id = row[0]
if not df.empty: if not target_snap_id:
rename_map = { return None
'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник',
'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня',
'time_in_building': 'Находился_в_здании', 'is_present': 'Пришел'
}
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
if 'fio_clean' not in df.columns and 'Сотрудник' in df.columns:
df['fio_clean'] = df['Сотрудник'].astype(str).str.strip()
if 'Пришел' in df.columns:
df['Пришел'] = df['Пришел'].astype(bool)
return df return load_scud_from_db_by_snapshot(date_str, snapshot_param=target_snap_id)
def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]: def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
df_staff, df_absences = load_1c_data_smart(date_str, use_db=False) """
return (df_staff if df_staff is not None else pd.DataFrame(), Загружает реестры штата и отсутствий 1С на указанную дату через data_loader.
df_absences if df_absences is not None else pd.DataFrame()) """
df_staff = load_staff_data(date_str)
df_abs = load_absent_data(date_str)
return df_staff, df_abs
+80
View File
@@ -0,0 +1,80 @@
"""
===============================================================================
FILE: services/scud_etl/svodka_generator.py
ROLE: Генератор Ежедневной Сводки (оперативный контроль, текущий срез).
===============================================================================
"""
import os
import logging
from typing import Dict, Any, Optional
import pandas as pd
from config import DATE_TODAY
from core.database import load_scud_from_db_by_snapshot
from services.scud_etl.pipeline import load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.snapshots.finder import find_or_create_snapshot_for_time
from services.excel_exporter import generate_summary_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("SVODKA_GENERATOR")
def generate_svodka_service(
target_date: Optional[str] = None,
target_time: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует оперативную сводку на указанную дату / время:
- target_date: дата сводки (по умолчанию сегодня).
- target_time: время среза (например '14:30').
- snapshot_id: точный ID среза.
"""
date_clean = str(target_date or DATE_TODAY).replace('_', '.')
applied_note = ""
# Если передано время, но не указан конкретный snapshot_id — ищем ближайший или запрашиваем экспорт
if target_time and not snapshot_id:
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=True)
snapshot_id = found_id
applied_note = note
if note:
logger.info(note)
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Срез СКУД за {date_clean} ({applied_note or snapshot_id or 'последний доступный'}) не найден в базе."
}
df_staff, df_abs = load_1c_files_for_date(date_clean)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
metrics = calculate_summary_metrics(df_merged)
anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud)
# Добавляем суффикс времени в имя файла, если сводка строилась на точный срез
time_suffix = f" на {target_time.replace(':', '-')}" if target_time else ""
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "SVODKA",
"date": date_clean,
"target_time": target_time,
"snapshot_id": snapshot_id or "AUTO_LATEST",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"metrics": metrics,
"anomalies_count": len(anomalies),
"note": applied_note,
"message": f"Ежедневная сводка на {date_clean} {target_time or ''} успешно сформирована."
}
+1 -1
View File
@@ -237,7 +237,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
continue continue
if is_yesterday: if is_yesterday:
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00" snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 23:59:59"
else: else:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+89
View File
@@ -0,0 +1,89 @@
"""
===============================================================================
FILE: services/snapshots/finder.py
ROLE: Поиск ближайшего снапшота в SQLite (Smart Snap-to-Grid) и On-Demand экспорт.
===============================================================================
"""
import logging
from datetime import datetime
from typing import Optional, Tuple
from core.connection import get_connection
from services.scud_export import run_export
logger = logging.getLogger("SNAPSHOT_FINDER")
def find_or_create_snapshot_for_time(
target_date_str: str,
target_time_str: str,
tolerance_minutes: int = 20,
allow_ondemand_export: bool = True
) -> Tuple[Optional[str], str]:
"""
Ищет ближайший срез за указанную дату и время (±tolerance_minutes).
Если не найден и allow_ondemand_export=True — запрашивает выгрузку из MS SQL на это время.
Возвращает: (snapshot_id, human_message)
"""
date_clean = target_date_str.replace('_', '.')
dt_target = datetime.strptime(f"{date_clean} {target_time_str}", "%d.%m.%Y %H:%M")
# 1. Поиск существующих снапшотов за эту дату в SQLite
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT DISTINCT snapshot_id, snapshot_time
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
""", (date_clean,))
rows = cursor.fetchall()
best_snapshot = None
min_diff_seconds = float('inf')
for snap_id, snap_time_str in rows:
try:
# Формат в базе: YYYY-MM-DD HH:MM:SS или DD.MM.YYYY HH:MM:SS
raw_time = str(snap_time_str).strip()
if '.' in raw_time.split()[0]:
dt_snap = datetime.strptime(raw_time, "%d.%m.%Y %H:%M:%S")
else:
dt_snap = datetime.strptime(raw_time, "%Y-%m-%d %H:%M:%S")
diff = abs((dt_snap - dt_target).total_seconds())
if diff < min_diff_seconds:
min_diff_seconds = diff
best_snapshot = (snap_id, snap_time_str, dt_snap)
except Exception:
continue
# Если найден срез в пределах допуска (по умолчанию 20 минут)
if best_snapshot and min_diff_seconds <= (tolerance_minutes * 60):
snap_id, snap_time, dt_s = best_snapshot
diff_mins = round(min_diff_seconds / 60)
return snap_id, f"Использован готовый срез {snap_id} за {dt_s.strftime('%H:%M')} (разница {diff_mins} мин)."
# 2. Если срез не найден и разрешен On-Demand экспорт из MS SQL Орион
if allow_ondemand_export:
logger.info(f"Снапшот на {date_clean} {target_time_str} не найден в SQLite. Запуск прямого среза из MS SQL...")
run_export(
input_date=date_clean,
debug=False,
save_xlsx=True
)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
ORDER BY id DESC LIMIT 1
""", (date_clean,))
row = cursor.fetchone()
if row:
return row[0], f"Создан новый срез {row[0]} из MS SQL на {target_time_str}."
return None, f"Срез на {date_clean} {target_time_str} не найден."
+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)