Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e8055a3d | ||
|
|
fa386b3b79 |
@@ -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
@@ -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
@@ -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
@@ -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].
|
||||||
+38
-14
@@ -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,8 +41,13 @@ def main():
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
if args.date:
|
||||||
|
today_str = args.date.replace('_', '.')
|
||||||
|
dt_target = datetime.strptime(today_str, "%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:
|
||||||
today_str = now.strftime("%d.%m.%Y")
|
today_str = now.strftime("%d.%m.%Y")
|
||||||
# ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня)
|
|
||||||
if now.weekday() == 0:
|
if now.weekday() == 0:
|
||||||
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
|
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
|
||||||
else:
|
else:
|
||||||
@@ -47,7 +56,7 @@ def main():
|
|||||||
# [Этап 0] Выгрузка свежих данных СКУД
|
# [Этап 0] Выгрузка свежих данных СКУД
|
||||||
if not args.skip_export and not args.snapshot:
|
if not args.skip_export and not args.snapshot:
|
||||||
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
|
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
|
||||||
run_export(debug=args.debug)
|
run_export(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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
"""Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
|
"""Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
|
||||||
|
|||||||
+51
-60
@@ -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)
|
||||||
|
|
||||||
@@ -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']
|
||||||
|
|
||||||
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
|
# ⭐️ ПРАВИЛО 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)
|
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('Подразделение', '')))
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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} успешно сформирован."
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 ''} успешно сформирована."
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
Reference in New Issue
Block a user