From d391a08224a9d237170f5c62a9bb09a1406cf95f Mon Sep 17 00:00:00 2001 From: manoraga Date: Mon, 7 Sep 2026 17:29:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20UI,=20Gemini-?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB=D0=B8=D0=BD=D0=B3,=20?= =?UTF-8?q?=D1=80=D0=BE=D1=83=D1=82=D0=B5=D1=80=D1=8B=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0/=D1=81=D0=BD=D0=B0=D0=BF?= =?UTF-8?q?=D1=88=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80?= =?UTF-8?q?=D0=BE=D0=B0=D0=B4=D0=BC=D0=B0=D0=BF=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + config.py | 2 +- core/repositories/scud_repo.py | 29 +- data/static_reason_workers.csv | 30 +- docs/CHANGELOG.md | 62 +- docs/in_progress/ROADMAP.md | 161 +- etl_code_snapshot.md | 1603 ++++++---- main_etl.py | 68 +- modules/web_api/llm/agent.py | 272 +- modules/web_api/llm/core/fast_path.py | 329 +- modules/web_api/llm/core/ollama_client.py | 2 +- modules/web_api/llm/core/tool_injector.py | 108 +- modules/web_api/llm/db_tools.py | 3 + modules/web_api/llm/schemas.py | 8 +- modules/web_api/main.py | 14 + modules/web_api/routers/auth.py | 14 +- modules/web_api/routers/chat.py | 147 +- modules/web_api/routers/context.py | 50 + modules/web_api/routers/remote_workers.py | 157 + modules/web_api/routers/snapshots.py | 53 + modules/web_api/static/index.html | 708 ++++- modules/web_api/static/js/auth.js | 87 +- modules/web_api/static/js/chat/core.js | 623 +++- modules/web_api/static/js/sidebar.js | 472 +++ modules/web_api/static/js/tasks.js | 255 +- services/data_loader.py | 90 +- services/excel_exporter.py | 501 ++-- services/scud_etl/merger.py | 41 +- services/scud_export.py | 152 +- services/share_copier.py | 11 +- services/snapshots/retention.py | 112 + services/tasks/exporter.py | 121 +- web_api_code_snapshot.md | 3310 +++++++++++++++++++++ 33 files changed, 7768 insertions(+), 1832 deletions(-) create mode 100644 modules/web_api/routers/context.py create mode 100644 modules/web_api/routers/remote_workers.py create mode 100644 modules/web_api/routers/snapshots.py create mode 100644 modules/web_api/static/js/sidebar.js create mode 100644 services/snapshots/retention.py create mode 100644 web_api_code_snapshot.md diff --git a/.gitignore b/.gitignore index cb84140..a847646 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ data/1c/* !data/scud/.gitkeep !data/1c/.gitkeep !data/static_reason_workers.csv + +*.db-shm +*.db-wal +data/scud_orion_ai.db +data/uploads/ diff --git a/config.py b/config.py index 8c4237c..24550b5 100644 --- a/config.py +++ b/config.py @@ -24,7 +24,7 @@ else: DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") OLLAMA_URL = "http://10.121.17.227:11434/api/generate" -OLLAMA_MODEL = "qwen2.5:14b-instruct-q8_0" +OLLAMA_MODEL = "qwen2.5:14b" MODEL_NAME = OLLAMA_MODEL KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") diff --git a/core/repositories/scud_repo.py b/core/repositories/scud_repo.py index e2df72c..e3d7541 100644 --- a/core/repositories/scud_repo.py +++ b/core/repositories/scud_repo.py @@ -47,6 +47,8 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste with get_connection() as conn: cursor = conn.cursor() + + # 1. Проверяем, существует ли уже срез с точно таким же временем и датой if date_str: cursor.execute( "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", @@ -62,19 +64,30 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste if row and row[0]: return row[0] + # 2. Извлекаем ВСЕ существующие ID за текущие календарные сутки cursor.execute(""" - SELECT snapshot_id FROM scud_logs + SELECT DISTINCT snapshot_id + FROM scud_logs WHERE snapshot_id LIKE ? OR snapshot_id LIKE ? - ORDER BY snapshot_id DESC LIMIT 1 """, (f"{date_prefix}-%", f"Y{date_prefix}-%")) + + rows = cursor.fetchall() + max_seq = 0 - last_row = cursor.fetchone() - next_seq = 1 - if last_row and last_row[0]: - parts = last_row[0].replace("Y", "").split("-") - if len(parts) > 1 and parts[1].isdigit(): - next_seq = int(parts[1]) + 1 + for (s_id,) in rows: + if not s_id: + continue + try: + # Извлекаем число после последнего дефиса + parts = str(s_id).split('-') + if len(parts) >= 2 and parts[-1].isdigit(): + num = int(parts[-1]) + if num > max_seq: + max_seq = num + except Exception: + continue + next_seq = max_seq + 1 return f"{prefix}{date_prefix}-{next_seq:03d}" diff --git a/data/static_reason_workers.csv b/data/static_reason_workers.csv index f7d96f6..5d7167c 100644 --- a/data/static_reason_workers.csv +++ b/data/static_reason_workers.csv @@ -1,16 +1,14 @@ -fio,reason,note -Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка -Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка -Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка -Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка -Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка -Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка -Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка -Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка -Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка -Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка -Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка -Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка -Чуркина Елена Геннадьевна,Удаленная работа,Постоянная удаленка -Пушков Александр Александрович,Удаленная работа,Временная удаленка с 26.08.2026 по 04.09.2026 -Сысоев Алексей Валерьевич,Удаленная работа,Временная удаленка с 25.08.2026 по 27.08.2026 \ No newline at end of file +fio,department,reason,date_from,date_to +Королёва Наталья Александровна,Все,Удаленная работа,, +Николаева Ирина Леонидовна,Все,Удаленная работа,, +Познякова Татьяна Сергеевна,Все,Удаленная работа,, +Софьин Никита Сергеевич,Все,Удаленная работа,, +Чуб Александр Васильевич,Все,Удаленная работа,, +Шуличенко Иван Иванович,Все,Удаленная работа,, +Пухаренко Юрий Владимирович,Все,Удаленная работа,, +Пшеничный Виктор Петрович,Все,Удаленная работа,, +Незнанова Валерия Игоревна,Все,Удаленная работа,, +Ковалев Владимир Владимирович,Все,Удаленная работа,, +Кожокарь Татьяна Юрьевна,Все,Удаленная работа,, +Субетто Юлия Викторовна,Все,Удаленная работа,, +Чуркина Елена Геннадьевна,Все,Удаленная работа,, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ad9fc49..5a44ad1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,64 @@ -# 📋 История изменений (CHANGELOG) +# Changelog -Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле. +Все важные изменения проекта документируются в этом файле. + +## [Unreleased] - 2026-09-07 + +### Добавлено +- Механизм Gemini-скроллинга диалогового окна: функция `scrollToUserMessageTop` в `core.js` автоматически поднимает отправленный вопрос к верхнему срезу экрана, скрывая громоздкие предыдущие ответы. +- CSS-отступ `padding-bottom: 80vh` у `#chat-messages-container`, гарантирующий свободное вертикальное пространство для прокрутки без сдвига строки ввода. +- Пункт 2.9 системного промпта, жестко обязывающий модель вызывать нативный инструмент `db_get_rules` при запросах Базы Знаний и регламентов вместо текстовой имитации. + +### Запланировано +- Рефакторинг интерпретации аппаратных кодов турникетов PERCO в `services/scud_export.py`: разделение логики разрешения прохода (`Event 28`) и факта проворота створки (`Event 32`), приоритет направления по `log.Mode` (1 — вход, 2 — выход) для устранения фантомных выходов. +- Создание DDL-схемы и таблицы `scud_events_raw` в SQLite для гранулярного хранения всех внутридневных перемещений сотрудников (база для учета перекуров и инструмента «Кто в здании»). + +## [0.9.5] - 2026-08-28 + +### Добавлено +- Механизм отказоустойчивого сохранения отчетов `safe_close_workbook` в `services/excel_exporter.py`: при блокировке файла открытым процессом Excel создается резервный файл с таймстемпом вида `..._timestamp.xlsx`. + +### Изменено +- Экспорт всех Excel-документов (`services/scud_export.py`, `services/excel_exporter.py`) полностью переведен с `openpyxl` на `xlsxwriter`. +- Скорректирована верстка детального отчета: столбец «Подразделение» выровнен строго по центру, колонка «ФИО» расширена на 10% (ширина 33), а избыточная ширина столбцов «Первая активность» и «Отклонение от нормы» уменьшена до 11 пунктов для корректного размещения на экране без горизонтальной прокрутки. +- В модуле сетевой синхронизации `services/share_copier.py` вызов `shutil.copy2` заменен на `shutil.copyfile`, что устранило ошибки метаданных `[Errno 1] Operation not permitted` при монтировании SMB-шары. + +### Исправлено +- Устранена системная ошибка структуры OpenXML (`errorXXXXXX_01.xml`, «Обнаружена ошибка в части содержимого книги...»), возникавшая при открытии сгенерированных отчетов в MS Excel. +- Восстановлены сворачиваемые интерактивные категории с кнопками `[+]` / `[-]` в файле ежедневной сводки (`outline_settings(symbols_below=False)`). + +## [Unreleased] - 2026-08-28 + +### Fixed +- **Генерация Excel-книг (`services/excel_exporter.py`, `services/scud_export.py`):** полный перевод экспорта с `openpyxl` на `xlsxwriter`, что устранило хроническую ошибку структуры OpenXML («В книге обнаружено нечитаемое содержимое...»). +- **Группировки в Сводке:** восстановлено корректное отображение интерактивных кнопок `[+]`/`[-]` и сворачивание категорий по умолчанию (`outline_settings`, `level=1`, `collapsed=True`). +- **Верстка и пропорции Детального отчета:** центрирование столбца «Подразделение», калибровка ширины колонок «ФИО» (+10%), «Первая активность» и «Отклонение от нормы» для плотного размещения таблицы на одном экране без горизонтальной прокрутки. +- **Безопасное сохранение при блокировках:** внедрена функция `safe_close_workbook` для фонового сохранения резервных копий с timestamp, если файл открыт пользователем в Excel. +- **Сетевое копирование 1С (`services/share_copier.py`):** замена `shutil.copy2` на `shutil.copyfile` для устранения сбоя `[Errno 1] Operation not permitted` при работе с SMB/CIFS-шарой. + +## [Unreleased] - 2026-08-28 + +### Fixed +- **Инкремент Snapshot ID (`core/repositories/scud_repo.py`):** заменена строковая сортировка `ORDER BY snapshot_id DESC` на целочисленный парсинг суффиксов, что устранило залипание номеров снапшотов (`-002`). + +### Added +- **Режим тихих почасовых срезов (`--export-only`):** быстрый экспорт данных СКУД напрямую в SQLite без построения отчетов для интеграции в cron. +- **Интерактивный справочник CLI (`main_etl.py`):** полный баннер справки по всем аргументам (`-h`, `--help`, `help`). +- **Автоматическая ротация промежуточных срезов (`services/snapshots/retention.py`):** ночная очистка устаревших почасовых срезов старше 2 дней с сохранением полуденного (`13:00`) и итогового `Y` (`23:59:59`). + +## [Unreleased] - 2026-08-28 + +### Added +- **Smart Snap-to-Grid Finder (`services/snapshots/finder.py`):** интеллектуальный поиск ближайшего среза СКУД в SQLite с допуском ±20 минут и поддержкой On-Demand экспорта из MS SQL при отсутствии готового среза. +- **Параметр `--time` в `main_etl.py`:** возможность формирования оперативной сводки на произвольное время суток. +- **Раздельные генераторы отчетов:** + - `services/scud_etl/svodka_generator.py` — оперативная сводка за сегодня с группировками. + - `services/scud_etl/otchet_generator.py` — суточный детальный отчет за вчера по финишному срезу `Y` (23:59:59) с учетом рабочего норматива 8.5 ч. + +### Changed +- **Фиксация финишного среза `Y` на `23:59:59`:** обновлено время снапшота в `services/scud_export.py`, `core/repositories/scud_repo.py` и `services/scud_etl/pipeline.py` с сохранением обратной совместимости со срезами на `22:00:00`. +- **Путь виртуального окружения в `run_cron_etl.sh`:** скорректирован на актуальный `/home/puh/projects/scud_ai/venv/bin/python`. +- **Игнорирование временных файлов SQLite:** добавлены `*.db-shm` и `*.db-wal` в `.gitignore`. ## [3.2.0] — 2026-08-27 ### 🧠 Интеллектуальный арбитраж личностей, агрегация мульти-пропусков и автозакрытие смен diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md index 29dd1e6..af5b912 100644 --- a/docs/in_progress/ROADMAP.md +++ b/docs/in_progress/ROADMAP.md @@ -1,76 +1,77 @@ # План реализации (ROADMAP) ## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]` -- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 3]. -- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 3]. +- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов. +- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``). --- ## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]` -- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 3]. +- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`. - [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 3]. - - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 3]. -- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 3]. -- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 3]. -- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 3]. + - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты. + - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение. +- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью. +- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`. +- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов. --- ## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]` -- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 3]. -- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 3]. -- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 3]. -- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 3]. -- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 3]. +- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**. +- [x] Сбрасывать состояние сессии в базе данных после фиксации решения. +- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`. +- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста. +- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории. --- ## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]` -- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 3]. -- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 3]. -- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 3]. -- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 3]. +- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**. +- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением. +- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`). +- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика. --- ## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]` -- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 3]. -- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 3]. -- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 3]. +- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`). +- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail). +- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`). +- [x] **Строгий вызов Базы Знаний:** внедрено правило 2.9 в системный промпт для пресечения текстовой имитации вызова `db_get_rules`. --- ## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]` - [x] **Масштабирование UI задач:** - - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 3]. - - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 3]. - - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 3]. + - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`. + - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все». + - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих). - [x] **Инлайн-редактирование карточки задачи:** - - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 3]. - - Отображение даты создания задачи[cite: 3]. - - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 3]. + - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности). + - Отображение даты создания задачи. + - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`. - [x] **Детерминированный Fast-Path и двухфазное удаление:** - - Мгновенная смена статусов без задержек LLM[cite: 3]. - - Карточка подтверждения удаления с автоочисткой контекста[cite: 3]. + - Мгновенная смена статусов без задержек LLM. + - Карточка подтверждения удаления с автоочисткой контекста. - [x] **Генерация отчетов задач в Markdown:** - - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3]. - - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3]. + - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`. + - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`). - [x] **Доменная консолидация задач:** - - Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 3]. + - Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`. --- ## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]` -- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 3]. -- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 3]. +- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00. +- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат. - [x] **Интерактивный UI с чекбоксами и защитой срезов:** - - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3]. - - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3]. - - Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 3]. + - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке. + - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора). + - Динамическая кнопка «Удалить выбранные (N)» в подвале карточки. - [x] **Детерминированный Fast-Path удаления срезов:** - - Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 3]. - - Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления[cite: 3]. + - Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов. + - Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления. --- @@ -109,39 +110,67 @@ --- -## 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 Орион при отсутствии подходящего среза. +## 10. Разделение генераторов и почасовые срезы `[ЗАВЕРШЕНО]` +- [x] Разделение логики генерации на `svodka_generator.py` и `otchet_generator.py`. +- [x] Перевод времени суточного среза `Y` на `23:59:59`. +- [x] Интеллектуальный поиск срезов `services/snapshots/finder.py` (Snap-to-Grid ±20 мин). +- [x] Флаг `--time` и интерактивный help в `main_etl.py`. +- [x] Флаг `--export-only` для почасового крона. +- [x] Политика ночной ротации промежуточных срезов `services/snapshots/retention.py`. +- [x] Исправление целочисленного инкремента `snapshot_id`. --- -## 11. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]` -- [ ] **Двухконтурный запуск ИИ:** - - [ ] Перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`. +## 11. Стабилизация генератора отчетов и верстки `[ЗАВЕРШЕНО]` +- [x] **Миграция на `XlsxWriter`:** полный перевод экспорта книг (`scud_export.py`, `excel_exporter.py`) на чистый генератор, устранивший ошибки OpenXML и окна восстановления при открытии. +- [x] **Интерактивные группировки в Сводке:** восстановление сворачивания категорий по умолчанию (`level=1`, `collapsed=True`) и кнопок управления уровнями `[+]`/`[-]` (`outline_settings(symbols_below=False)`). +- [x] **Компактная экранная верстка Детального отчета:** центрирование столбца «Подразделение», расширение столбца «ФИО» (+10%, ширина 33), оптимизация ширины колонок «Первая активность» (11) и «Отклонение от нормы» (11) для отображения без горизонтальной прокрутки. +- [x] **Защита от блокировок занятых файлов (Fallback Timestamp):** механизм перехвата `FileCreateError`/`OSError` в `safe_close_workbook` с сохранением копии при открытом в Excel файле. +- [x] **Безопасная синхронизация 1С с сетевой шары:** переход на `shutil.copyfile` в `share_copier.py` для устранения сбоев прав доступа (`Operation not permitted`) на SMB/CIFS-ресурсах. + +--- + +## 12. UI/UX веб-интерфейса и скроллинг ленты сообщений `[ЗАВЕРШЕНО]` +- [x] **Паттерн Gemini-скролла:** реализация функции `scrollToUserMessageTop` в `core.js`, позиционирующей свежий вопрос пользователя строго по верхней кромке видимой области. +- [x] **Полное скрытие истории:** предыдущие объемные ответы модели гарантированно вытесняются за верхний край экрана. +- [x] **Стабилизация панели ввода:** настройка `padding-bottom: 80vh` для `#chat-messages-container` в `index.html`, обеспечивающая необходимый запас высоты прокрутки без сдвига строки ввода текста. + +--- + +## 13. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]` +- [ ] **Двухконтурный вызов ИИ:** перенос арбитража не сопоставившихся персон на этап [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`). - - [ ] Защита от смены фамилий и опечаток через неизменяемый табельный номер. -- [ ] **Динамическая инвалидация кэша связок:** - - [ ] Автоматическая отбраковка записей кэша при увольнении сотрудника или несовпадении подразделения. + - [ ] Извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП. + - [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite. + - [ ] Защита от смены фамилий и опечаток через инвариант табельного номера. +- [ ] **4-уровневая система предохранителей (Guardrails):** + - Уровень 1: Точный матч ФИО (100%). + - Уровень 2: Матч по табельному номеру при расхождении ФИО. + - Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С. + - Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш. + --- -## 12. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]` +## 14. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]` - [ ] **Docker/gVisor контур:** - - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 3]. - - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 3]. + - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups). + - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only. - [ ] **Динамические Python/Pandas вычисления:** - - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 3]. - - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 3]. \ No newline at end of file + - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету. + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата. + +--- + +## 15. Архитектура обработки событий турникетов и детализация проходов (Сырые логи СКУД) `[В РАБОТЕ]` +- [ ] **Итерация 1: Устранение фантомных выходов в боевом SQL (`scud_export.py`):** + - [ ] Перестроить блок `CASE WHEN` определения `Direction`: приоритет отдавать физическому направлению контроллера (`log.Mode = 1` -> `'IN'`, `log.Mode = 2` -> `'OUT'`). + - [ ] Исключить универсальные коды событий `28` (разблокировка створки) и `32` (проворот турникета) из жесткого перечня `'IN'`, так как они зеркальны для обоих направлений. + - [ ] Закрепить расчет первого входа `FirstIn` и последнего выхода `FilteredLastOut` (строго `OUT` позже `FirstIn`). +- [ ] **Итерация 2: DDL-схема и таблица сырых событий (`core/schema.py`):** + - [ ] Создать таблицу `scud_events_raw` (поля: `log_date`, `time_val`, `hoz_organ`, `fio_clean`, `event_code`, `mode`, `direction`, `created_at`). + - [ ] Настроить индексы `idx_raw_date_fio` и `idx_raw_mode` для быстрых выборок. +- [ ] **Итерация 3: Конвейер выгрузки сырых событий (`services/scud_export.py`):** + - [ ] Добавить сохранение всех фактов физических проходов (`Event = 32` / `Event = 28`) в `scud_events_raw` параллельно с формированием агрегированного снапшота в `scud_logs`. +- [ ] **Итерация 4: Модуль оперативного присутствия и внутридневного учета:** + - [ ] Реализовать инструмент «Кто прямо сейчас в здании» по последнему зафиксированному `Mode` сотрудника за текущие сутки. + - [ ] Подготовить расчет реального суммарного времени нахождения на рабочем месте с учетом перекуров, обедов и выходов за периметр. \ No newline at end of file diff --git a/etl_code_snapshot.md b/etl_code_snapshot.md index 002af36..545176f 100644 --- a/etl_code_snapshot.md +++ b/etl_code_snapshot.md @@ -14,7 +14,6 @@ ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") OUTPUT_DIR = os.path.join(BASE_DIR, "output") REPORTS_DIR = os.path.join(OUTPUT_DIR, "reports") -# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат) SHARE_1C_DIR = "/mnt/scud_share" for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR, REPORTS_DIR]: @@ -28,32 +27,30 @@ if NOW.weekday() == 0: else: DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") -# --- Настройки LLM / Ollama --- OLLAMA_URL = "http://10.121.17.227:11434/api/generate" -OLLAMA_MODEL = "qwen2.5:14b-instruct-q8_0" +OLLAMA_MODEL = "qwen2.5:14b" MODEL_NAME = OLLAMA_MODEL KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") -# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) --- ZUP_SQL_CONFIG = { - "driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}" + "driver": "{ODBC Driver 18 for SQL Server}", "server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"), - "database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01 - "user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server - "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server - "trust_server_certificate": "yes", # Доверять сертификату сервера - "encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование + "database": os.getenv("ZUP_SQL_DB", "ZUP30"), + "user": os.getenv("ZUP_SQL_USER", "scud_reader"), + "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), + "trust_server_certificate": "yes", + "encrypt": "no" } -def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]): - """ - Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026). - """ - date_dots = date_str - date_underscores = date_str.replace('.', '_') +def find_dated_file(prefix, date_str, search_dirs=None): + if search_dirs is None: + search_dirs = [ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."] + + date_dots = str(date_str).replace('_', '.') + date_underscores = date_dots.replace('.', '_') for d in search_dirs: if not os.path.exists(d): @@ -80,6 +77,18 @@ def clean_scud_fio_light(fio_str): def load_exceptions(): + """ + Приоритетно читает исключения и белый список из SQLite таблицы exceptions_registry. + При отсутствии таблицы или пустой базе выполняет fallback на exceptions.json. + """ + try: + from services.exceptions_repo import get_all_exceptions_from_db + db_exc = get_all_exceptions_from_db() + if any(db_exc.values()): + return db_exc + except Exception: + pass + import json if os.path.exists(EXCEPTIONS_PATH): try: @@ -87,7 +96,7 @@ def load_exceptions(): return json.load(f) except Exception: pass - return {"fio": [], "departments": [], "positions": [], "position_keywords": []} + return {"fio": [], "departments": [], "positions": [], "position_keywords": [], "include_fio": []} ``` ## File: `./exceptions.json` @@ -139,41 +148,106 @@ 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.merger import merge_scud_and_1c, calculate_summary_metrics 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.snapshots.retention import cleanup_old_intermediate_snapshots from services.scud_export import run_export 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") +def print_help(): + print(""" +=============================================================================== +🛠️ SCUD ORION AI — СИСТЕМА КОНТРОЛЛИНГА И СВОДНЫХ ОТЧЕТОВ +=============================================================================== +Использование: + python main_etl.py [ОПЦИИ] + +Доступные аргументы: + -h, --help, help Показать эту справку и выйти + --date ДД.ММ.ГГГГ Дата расчета (по умолчанию: текущий рабочий день) + --time ЧЧ:ММ Время среза для сводки (например: 14:30) + Ищет ближайший срез (±20 мин) или запрашивает On-Demand экспорт + --snapshot ID Точный ID снапшота для расчета (например: 20260827-002) + --skip-export Пропустить выгрузку СКУД из MS SQL (работать только с SQLite) + --export-only ТОЛЬКО сделать экспорт/снапшот СКУД в БД без построения отчетов + -d, --debug Включить режим расширенной отладки + +Примеры использования: + python main_etl.py + 👉 Полный суточный цикл: экспорт -> отчет за вчера -> сводка за сегодня -> ИИ. + + python main_etl.py --export-only + 👉 Почасовой тихий срез в БД (для cron) без генерации отчетов. + + python main_etl.py --date 27.08.2026 --time 12:15 --skip-export + 👉 Построить сводку за 27.08 на 12:15 без запроса к внешнему MS SQL. + + python main_etl.py --snapshot 20260827-001 --skip-export + 👉 Расчет отчетов строго по выбранному снапшоту из SQLite. +=============================================================================== +""") + + def main(): - parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С") - parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки") - parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL") - parser.add_argument("--snapshot", type=str, default=None, help="ID снапшота для расчета") + if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"): + print_help() + sys.exit(0) + + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("-h", "--help", action="store_true") + parser.add_argument("-d", "--debug", action="store_true") + parser.add_argument("--skip-export", action="store_true") + parser.add_argument("--export-only", action="store_true") + parser.add_argument("--date", type=str, default=None) + parser.add_argument("--time", type=str, default=None) + parser.add_argument("--snapshot", type=str, default=None) + args = parser.parse_args() + if args.help: + print_help() + sys.exit(0) print("=" * 60) print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}") print("=" * 60) + # Автоматическая ротация архивных почасовых срезов + if not args.skip_export and not args.snapshot: + deleted_count = cleanup_old_intermediate_snapshots(days_to_keep_all=2) + if deleted_count > 0: + print(f"[🧹] Ротация БД: очищено {deleted_count} строк промежуточных архивных срезов.") + now = datetime.now() - today_str = now.strftime("%d.%m.%Y") - # ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня) - if now.weekday() == 0: - yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") + 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: - yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y") + today_str = now.strftime("%d.%m.%Y") + if now.weekday() == 0: + yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") + else: + yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y") # [Этап 0] Выгрузка свежих данных СКУД if not args.skip_export and not args.snapshot: 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: print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...") + # Если запрошен режим тихого почасового среза — выходим без тяжелых генераций + if args.export_only: + print(f"\n[✓] Режим --export-only: срез зафиксирован в SQLite. Генерация отчетов пропущена.") + sys.exit(0) + # [Этап 0.5] Синхронизация файлов с шары 1С if not args.snapshot and not args.skip_export: print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...") @@ -189,21 +263,33 @@ def main(): 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) - # [Этап 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})...") - df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday) - generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str) + res_otchet = generate_otchet_service(target_date=yesterday_str) + if res_otchet.get("status") == "success": + print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}") - # [Этап 4] Сводка за сегодня - print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") - df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) - metrics_today = calculate_summary_metrics(df_merged_today) - anomalies_today = detect_registry_anomalies(df_merged_today) - generate_summary_excel(df_merged_today, date_str=today_str) + # [Этап 4] Сводка за сегодня через svodka_generator + print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...") + res_svodka = generate_svodka_service( + target_date=today_str, + target_time=args.time, + 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)...") + 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[ (df_merged_today['Пришел'] == False) & (df_merged_today['Вид_отсутствия'].notna()) & @@ -221,6 +307,9 @@ def main(): absent_unexplained=absent_unexplained, scud_present_but_absent_in_1c=pd.DataFrame(), 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 ) @@ -292,6 +381,44 @@ from services.exceptions_repo import ( 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(): """Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" @@ -971,7 +1098,7 @@ def init_all_tables() -> None: ); """) - # 3. Аномалии и база знаний + # 3. Аномалии, база знаний и синонимы cursor.execute(""" CREATE TABLE IF NOT EXISTS anomalies_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1002,7 +1129,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(""" CREATE TABLE IF NOT EXISTS system_prompt_nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1036,18 +1190,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(""" CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1065,8 +1207,7 @@ def init_all_tables() -> None: # 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_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_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_mapping_scud ON person_identity_mapping(scud_fio);") conn.commit() ``` @@ -1096,7 +1237,7 @@ def has_yesterday_final_snapshot(date_str: str) -> bool: with get_connection() as conn: cursor = conn.cursor() cursor.execute( - "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1", + "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,) ) return cursor.fetchone() is not None @@ -1122,6 +1263,8 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste with get_connection() as conn: cursor = conn.cursor() + + # 1. Проверяем, существует ли уже срез с точно таким же временем и датой if date_str: cursor.execute( "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", @@ -1137,19 +1280,30 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste if row and row[0]: return row[0] + # 2. Извлекаем ВСЕ существующие ID за текущие календарные сутки cursor.execute(""" - SELECT snapshot_id FROM scud_logs + SELECT DISTINCT snapshot_id + FROM scud_logs WHERE snapshot_id LIKE ? OR snapshot_id LIKE ? - ORDER BY snapshot_id DESC LIMIT 1 """, (f"{date_prefix}-%", f"Y{date_prefix}-%")) + + rows = cursor.fetchall() + max_seq = 0 - last_row = cursor.fetchone() - next_seq = 1 - if last_row and last_row[0]: - parts = last_row[0].replace("Y", "").split("-") - if len(parts) > 1 and parts[1].isdigit(): - next_seq = int(parts[1]) + 1 + for (s_id,) in rows: + if not s_id: + continue + try: + # Извлекаем число после последнего дефиса + parts = str(s_id).split('-') + if len(parts) >= 2 and parts[-1].isdigit(): + num = int(parts[-1]) + if num > max_seq: + max_seq = num + except Exception: + continue + next_seq = max_seq + 1 return f"{prefix}{date_prefix}-{next_seq:03d}" @@ -1414,6 +1568,7 @@ ROLE: Надежная загрузка штата и отсутствий (MS S import os import warnings +from datetime import datetime # <-- ДОБАВИТЬ ЭТУ СТРОКУ import pandas as pd from config import ( normalize_fio, clean_scud_fio_light, load_exceptions, @@ -1508,27 +1663,84 @@ def load_absent_data(date_str): if df_absent is None: df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) - # Обогащение удаленщиками из static_reason_workers.csv + # Обогащение удаленщиками из static_reason_workers.csv с ротацией просроченных записей try: static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") if os.path.exists(static_path): - df_static = pd.read_csv(static_path, encoding='utf-8') - if 'fio' in df_static.columns and 'reason' in df_static.columns: - df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) - existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() - - new_rows = [] - for _, s_row in df_static.iterrows(): - if s_row['fio_clean'] not in existing_fios: - new_rows.append({ - 'fio_clean': s_row['fio_clean'], - 'Вид_отсутствия': s_row['reason'] + df_static = pd.read_csv(static_path, dtype=str, on_bad_lines='skip').fillna("") + + for col in ['fio', 'reason', 'department', 'date_from', 'date_to']: + if col not in df_static.columns: + df_static[col] = "" + + today_date = datetime.now().date() + + # Разбор целевой даты отчета + clean_target_str = str(date_str).replace('_', '.') + try: + clean_target_date = datetime.strptime(clean_target_str, "%d.%m.%Y").date() + except ValueError: + clean_target_date = today_date + + def parse_date_safe(d_val): + if not d_val or str(d_val).lower() in ['nan', 'none', '', 'nat']: + return None + s = str(d_val).strip().replace('_', '.') + for fmt in ("%d.%m.%Y", "%Y-%m-%d"): + try: + return datetime.strptime(s, fmt).date() + except ValueError: + pass + return None + + active_for_file = [] + new_rows_for_report = [] + file_changed = False + + existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() + + for _, s_row in df_static.iterrows(): + fio_raw = str(s_row.get('fio', '')).strip() + if not fio_raw: + continue + + fio_c = normalize_fio(fio_raw) + reason = str(s_row.get('reason', '')).strip() or "Дистанционная работа" + + d_from = parse_date_safe(s_row.get('date_from')) + d_to = parse_date_safe(s_row.get('date_to')) + + # 1. Физическая ротация просроченных: только если текущий реальный день (today) строго больше date_to + if d_to is not None and today_date > d_to: + print(f" [🧹] Удаленка истекла: {fio_raw} (до {d_to.strftime('%d.%m.%Y')}). Удалена из CSV.") + file_changed = True + continue + + active_for_file.append(s_row.to_dict()) + + # 2. Проверка действия удаленки на дату формируемого отчета: + # Если date_from не указана — действует всегда до date_to + is_after_start = (d_from is None) or (clean_target_date >= d_from) + is_before_end = (d_to is None) or (clean_target_date <= d_to) + + if is_after_start and is_before_end: + if fio_c not in existing_fios: + new_rows_for_report.append({ + 'fio_clean': fio_c, + 'Вид_отсутствия': reason }) - if new_rows: - df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True) - print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows)} чел. из static_reason_workers.csv") + existing_fios.add(fio_c) + + # Перезаписываем CSV только если реально были удалены просроченные сотрудники + if file_changed: + pd.DataFrame(active_for_file).to_csv(static_path, index=False, encoding='utf-8') + print(" [✓] Файл static_reason_workers.csv синхронизирован без просроченных записей.") + + if new_rows_for_report: + df_absent = pd.concat([df_absent, pd.DataFrame(new_rows_for_report)], ignore_index=True) + print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows_for_report)} чел. в отчет за {date_str}") except Exception as e: - print(f" [⚠️] Ошибка чтения static_reason_workers.csv: {e}") + print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}") return df_absent if not df_absent.empty else None @@ -1552,8 +1764,11 @@ def load_1c_data_smart(date_str, use_db=False): ## File: `./services/scud_export.py` ```py """ -Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel. -Добавлена фиксация Первой Активности (без учета направления) и среза по времени. +=============================================================================== +FILE: services/scud_export.py +ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter). + Корректная фильтрация транзитных проходов турникетов парковки и двора. +=============================================================================== """ import argparse @@ -1563,9 +1778,14 @@ import sys import warnings from datetime import datetime, timedelta +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "..")) +if ROOT_DIR not in sys.path: + sys.path.insert(0, ROOT_DIR) + import pandas as pd import pyodbc -from openpyxl.utils import get_column_letter +import xlsxwriter from config import SCUD_DIR, clean_scud_fio_light, load_exceptions from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot @@ -1584,12 +1804,17 @@ logger.setLevel(logging.INFO) logger.propagate = False if not logger.handlers: - _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + try: + _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + _file_handler.setFormatter(_formatter) + logger.addHandler(_file_handler) + except (PermissionError, OSError) as e: + sys.stderr.write(f"Предупреждение: невозможно создать лог-файл {LOG_FILE}: {e}\n") + _console_handler = logging.StreamHandler(sys.stdout) _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - _file_handler.setFormatter(_formatter) _console_handler.setFormatter(_formatter) - logger.addHandler(_file_handler) logger.addHandler(_console_handler) @@ -1626,8 +1851,8 @@ WITH DailyLogs AS ( log.Event, log.Mode, CASE - WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT' - WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN' + WHEN log.Mode = 2 OR log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT' + WHEN log.Mode = 1 OR log.Event IN (1, 21, 26, 28, 32, 54, 64) THEN 'IN' ELSE 'OTHER' END AS Direction, ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc @@ -1635,7 +1860,7 @@ WITH DailyLogs AS ( WHERE log.TimeVal BETWEEN @StartDate AND @EndDate AND log.HozOrgan IS NOT NULL AND log.HozOrgan > 0 - AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65) + AND log.Event IN (1, 2, 21, 26, 27, 28, 29, 32, 33, 54, 55, 64, 65) ), Passages AS ( SELECT @@ -1643,10 +1868,22 @@ Passages AS ( MIN(TimeVal) AS FirstRawEvent, MAX(TimeVal) AS LastRawEvent, MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, - MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut, - MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType + -- Реальный выход: должен быть позже входа минимум на 5 минут (отсекает проезд через шлагбаум/парковку) + MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS RawLastOut FROM DailyLogs GROUP BY EmployeeID +), +EvaluatedPassages AS ( + SELECT + p.*, + CASE + WHEN p.RawLastOut IS NOT NULL + AND p.FirstIn IS NOT NULL + AND p.RawLastOut > DATEADD(MINUTE, 5, p.FirstIn) + THEN p.RawLastOut + ELSE NULL + END AS FilteredLastOut + FROM Passages p ) SELECT N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма], @@ -1668,12 +1905,8 @@ SELECT ELSE N'—' END AS [Первая_активность], CASE - WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) - THEN N'Нет выхода' - WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn - THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20)) - WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent) - THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20)) + WHEN pass.FilteredLastOut IS NOT NULL + THEN CAST(CONVERT(VARCHAR(8), pass.FilteredLastOut, 108) AS NVARCHAR(20)) ELSE N'Нет выхода' END AS [Конец_дня], CASE @@ -1681,14 +1914,16 @@ SELECT RIGHT('0' + CAST(DATEDIFF(MINUTE, ISNULL(pass.FirstIn, pass.FirstRawEvent), CASE - WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() - ELSE ISNULL(pass.LastOut, pass.LastRawEvent) + WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut + WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE() + ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent) END) / 60 AS VARCHAR), 2) + ':' + RIGHT('0' + CAST(DATEDIFF(MINUTE, ISNULL(pass.FirstIn, pass.FirstRawEvent), CASE - WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() - ELSE ISNULL(pass.LastOut, pass.LastRawEvent) + WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut + WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE() + ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent) END) % 60 AS VARCHAR), 2) ELSE N'00:00' END AS [Находился_в_здании], @@ -1699,7 +1934,7 @@ SELECT FROM pList p WITH (NOLOCK) LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID -LEFT JOIN Passages pass ON p.ID = pass.EmployeeID +LEFT JOIN EvaluatedPassages pass ON p.ID = pass.EmployeeID WHERE ISNULL(p.StatusRecord, 0) = 0 AND p.DateTimeInArchive IS NULL @@ -1717,24 +1952,54 @@ ORDER BY p.Name ASC; """ -def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): - from openpyxl import load_workbook +def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"): + workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False}) + worksheet = workbook.add_worksheet(sheet_name) - wb = load_workbook(file_path) - ws = wb[sheet_name] + fmt_header = workbook.add_format({ + 'bold': True, + 'bg_color': '#D9E1F2', + 'border': 1, + 'border_color': '#D3D3D3', + 'align': 'center', + 'valign': 'vcenter', + 'font_name': 'Calibri', + 'font_size': 11 + }) - for col_cells in ws.columns: - max_len = 0 - col_letter = get_column_letter(col_cells[0].column) - for cell in col_cells: - if cell.value is not None: - cell_len = len(str(cell.value)) - if cell_len > max_len: - max_len = cell_len - width = max(min_width, min(max_len + padding, max_width)) - ws.column_dimensions[col_letter].width = width + fmt_cell = workbook.add_format({ + 'border': 1, + 'border_color': '#D3D3D3', + 'valign': 'vcenter', + 'align': 'left', + 'font_name': 'Calibri', + 'font_size': 11 + }) - wb.save(file_path) + headers = list(df.columns) + col_widths = [len(str(h)) for h in headers] + + for col_idx, header in enumerate(headers): + worksheet.write(0, col_idx, str(header), fmt_header) + + for row_idx, row_values in enumerate(df.values, start=1): + for col_idx, val in enumerate(row_values): + if pd.isna(val) or val is None: + val_str = "" + elif isinstance(val, bool): + val_str = "Да" if val else "Нет" + else: + val_str = str(val) + + worksheet.write(row_idx, col_idx, val_str, fmt_cell) + + if len(val_str) > col_widths[col_idx]: + col_widths[col_idx] = len(val_str) + + for col_idx, width in enumerate(col_widths): + worksheet.set_column(col_idx, col_idx, min(max(width + 3, 10), 45)) + + workbook.close() def get_targets(input_date: str | None): @@ -1783,14 +2048,12 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo period_label = target["name"] is_yesterday = (period_label == "Вчера") - # ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE: - # Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL if is_yesterday and has_yesterday_final_snapshot(processing_date_str): log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.") continue 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: snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -1826,8 +2089,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo except OSError as e: log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") - df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") - auto_fit_columns(file_path, sheet_name="Отчет") + save_df_to_clean_excel(df, file_path, sheet_name="Отчет") log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") else: log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING") @@ -1844,22 +2106,10 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo if __name__ == "__main__": - help_text = """ -Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel. - -ПРИМЕРЫ ЗАПУСКА: - python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера - python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату - python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX - python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG) -""" - parser = argparse.ArgumentParser( - description=help_text, - formatter_class=argparse.RawDescriptionHelpFormatter - ) - parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)") - parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL") - parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/") + parser = argparse.ArgumentParser() + parser.add_argument("--date", dest="input_date", default=None) + parser.add_argument("-d", "--debug", action="store_true") + parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True) args = parser.parse_args() run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug) @@ -1867,6 +2117,13 @@ if __name__ == "__main__": ## File: `./services/share_copier.py` ```py +""" +=============================================================================== +FILE: services/share_copier.py +ROLE: Синхронизация файлов 1С (Штат и Отсутствия) с сетевой шары в data/1c/. +=============================================================================== +""" + import os import shutil from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR @@ -1921,7 +2178,9 @@ def copy_1c_files_from_share(): local_target_path = os.path.join(ZUP_1C_DIR, filename) try: - shutil.copy2(remote_file, local_target_path) + # shutil.copyfile копирует только содержимое потока байтов + # без попыток изменить POSIX-права/атрибуты (chmod) на CIFS/SMB шаре + shutil.copyfile(remote_file, local_target_path) print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/") copied_count += 1 except Exception as e: @@ -1940,15 +2199,21 @@ def copy_1c_files_from_share(): ## File: `./services/excel_exporter.py` ```py -import math +""" +=============================================================================== +FILE: services/excel_exporter.py +ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter. + Корректный расчет часов удаленщиков и исключение лишних списков. +=============================================================================== +""" + import os +import math import time -import openpyxl import pandas as pd -from datetime import datetime -from openpyxl import Workbook -from openpyxl.styles import Font, Alignment, PatternFill, Border, Side -from openpyxl.utils import get_column_letter +import xlsxwriter +from datetime import datetime, timedelta +from xlsxwriter.exceptions import FileCreateError from config import REPORTS_DIR MONTHS_RU_GENITIVE = { @@ -1965,16 +2230,18 @@ MONTHS_RU_NOMINATIVE = { def format_date_ru(date_str): + date_clean = str(date_str).replace('_', '.') 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}" except Exception: return date_str def get_dated_reports_dir(date_str): + date_clean = str(date_str).replace('_', '.') try: - dt = datetime.strptime(date_str, "%d.%m.%Y") + dt = datetime.strptime(date_clean, "%d.%m.%Y") year_str = str(dt.year) month_name = MONTHS_RU_NOMINATIVE[dt.month] except Exception: @@ -1987,31 +2254,58 @@ def get_dated_reports_dir(date_str): return target_dir -THIN_SIDE = Side(border_style="thin", color="D3D3D3") -THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE) +def safe_close_workbook(wb, output_path, target_dir, filename): + try: + wb.close() + print(f"[✓] Успешно сохранен: {output_path}") + return output_path + except (FileCreateError, OSError, PermissionError): + alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") + alt_path = os.path.join(target_dir, alt_filename) + try: + wb.filename = alt_path + wb._store_workbook() + print(f"[⚠️] Исходный файл открыт в Excel! Сохранено как: {alt_path}") + return alt_path + except Exception as e: + print(f"[❌] Ошибка сохранения даже резервного файла: {e}") + return output_path -FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") -FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") -FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") -FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") -FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", 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_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") # Мягкий бежево-серый для исключений -CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"] +def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]: + 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 -YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") -LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") -GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") -LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") + 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): - if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "": + """ + Расчет отклонения от нормы. + Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение. + """ + reason_clean = str(reason).strip().lower() if pd.notna(reason) else "" + is_remote = "удален" in reason_clean or "дистанцион" in reason_clean + + has_building_time = isinstance(time_in_building_str, str) and time_in_building_str not in ['00:00', '0', '', 'None', 'nan', 'NaN'] + + # Если есть уважительная причина (больничный, отпуск, командировка и т.д.) не удаленка + if reason_clean != "" and not is_remote: return "0:00" - if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']: + # Если удаленщик работал исключительно из дома (00:00 в здании) + if is_remote and not has_building_time: + return "0:00" + + # Если сотрудника не было в здании и нет уважительной причины + if not has_building_time: return f"-{norm_hours}:00" try: @@ -2021,7 +2315,7 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min total_in_building_minutes = hh * 60 + mm if total_in_building_minutes == 0: - return f"-{norm_hours}:00" + return "0:00" if is_remote else f"-{norm_hours}:00" work_minutes = max(0, total_in_building_minutes - lunch_minutes) norm_minutes = norm_hours * 60 @@ -2037,100 +2331,105 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min return f"{sign}{res_hh}:{res_mm:02d}" except Exception: - return f"-{norm_hours}:00" + return "0:00" if is_remote else f"-{norm_hours}:00" -def apply_borders_to_cell(cell, border=THIN_BORDER): - cell.border = border - - -def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False): - cell_a = ws.cell(row=r_num, column=1) - cell_b = ws.cell(row=r_num, column=2) - if fill_obj: - cell_a.fill = fill_obj - cell_b.fill = fill_obj - apply_borders_to_cell(cell_a) - apply_borders_to_cell(cell_b) - if is_bold and bold_font: - cell_a.font = bold_font - cell_b.font = bold_font - if align_b: - cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b) - - -# --- 1. СВОДКА НА СЕГОДНЯ --- +# ============================================================================= +# 1. ЕЖЕДНЕВНАЯ СВОДКА НА СЕГОДНЯ +# ============================================================================= def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): + date_clean = str(date_str).replace('_', '.') 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) - wb = Workbook() - ws = wb.active - ws.title = "Лист_1" - ws.sheet_properties.outlinePr.summaryBelow = False - ws.sheet_properties.outlinePr.summaryRight = False - ws.sheet_properties.outlinePr.showOutlineSymbols = True - ws.sheet_view.showOutlineSymbols = True + wb = xlsxwriter.Workbook(output_path) + ws = wb.add_worksheet("Лист_1") - bold_font = Font(name="Calibri", size=11, bold=True) + ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False) - # 1. Шапка - ws.cell(row=1, column=1, value="Сводка на") - ws.cell(row=1, column=2, value=date_str) - format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) + def make_fmt(bg_color=None, bold=False, align="left", wrap=False): + d = { + 'font_name': 'Calibri', + 'font_size': 11, + 'bold': bold, + 'align': align, + 'valign': 'vcenter', + 'border': 1, + 'border_color': '#D3D3D3', + 'text_wrap': wrap + } + if bg_color: + d['bg_color'] = bg_color + return wb.add_format(d) - apply_borders_to_cell(ws.cell(row=2, column=1)) - apply_borders_to_cell(ws.cell(row=2, column=2)) + fmt_hdr_l = make_fmt(bg_color='#D9E1F2', bold=True, align="left") + fmt_hdr_r = make_fmt(bg_color='#D9E1F2', bold=True, align="right") + fmt_tot_l = make_fmt(bg_color='#F2F2F2', bold=True, align="left") + fmt_tot_r = make_fmt(bg_color='#F2F2F2', bold=True, align="right") + fmt_empty = make_fmt() - # 2. По списку - ws.cell(row=3, column=1, value="По списку") - 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) + ws.set_row(0, 20) + ws.write(0, 0, "Сводка на", fmt_hdr_l) + ws.write(0, 1, date_clean, fmt_hdr_r) - current_row = 4 + ws.set_row(1, 20) + ws.write(1, 0, "", fmt_empty) + ws.write(1, 1, "", fmt_empty) + ws.set_row(2, 20) + ws.write(2, 0, "По списку", fmt_tot_l) + ws.write(2, 1, len(merged_df), fmt_tot_r) + + current_row = 3 is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False is_exc = merged_df.get('is_excluded', False) == True - # 3. НЕИЗВЕСТНО + # 1. Неизвестно (Раскрыто по умолчанию) unexplained = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (~is_no_pass) & (~is_exc) ] - ws.cell(row=current_row, column=1, value="неизвестно") - ws.cell(row=current_row, column=2, value=len(unexplained)) - format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) + fmt_unexp_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left") + fmt_unexp_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right") + fmt_unexp_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left") + fmt_unexp_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "неизвестно", fmt_unexp_hl) + ws.write(current_row, 1, len(unexplained), fmt_unexp_hr) current_row += 1 for fio in sorted(unexplained['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = False + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) + ws.write(current_row, 0, fio, fmt_unexp_rl) + ws.write(current_row, 1, "", fmt_unexp_rr) current_row += 1 - # 4. РАЗДЕЛ: НЕТ ПРОПУСКА - no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() + # 2. Нет пропуска (Раскрыто по умолчанию) + no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() + fmt_np_hl = make_fmt(bg_color='#E1F5FE', bold=True, align="left") + fmt_np_hr = make_fmt(bg_color='#E1F5FE', bold=True, align="right") + fmt_np_rl = make_fmt(bg_color='#E1F5FE', bold=False, align="left") + fmt_np_rr = make_fmt(bg_color='#E1F5FE', bold=False, align="right") - ws.cell(row=current_row, column=1, value="Нет пропуска") - ws.cell(row=current_row, column=2, value=len(no_pass_df)) - format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) + ws.set_row(current_row, 20) + ws.write(current_row, 0, "Нет пропуска", fmt_np_hl) + ws.write(current_row, 1, len(no_pass_df), fmt_np_hr) current_row += 1 if not no_pass_df.empty: for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = False + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) + ws.write(current_row, 0, fio, fmt_np_rl) + ws.write(current_row, 1, "", fmt_np_rr) current_row += 1 - # 5. КАТЕГОРИИ ОТСУТСТВИЙ (без удаленки и исключений) + # 3. Официальные отсутствия (Сотрудники из исключений при наличии документа 1С попадают сюда) reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) @@ -2138,60 +2437,63 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & - (~is_remote_reason) & - (~is_exc) + (~is_remote_reason) ] absent_groups = absent_only.groupby('Вид_отсутствия') - - for idx_cat, (cat_name, group) in enumerate(absent_groups): - hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)] - cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid") + pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF'] - ws.cell(row=current_row, column=1, value=cat_name) - ws.cell(row=current_row, column=2, value=len(group)) - format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) + for idx_cat, (cat_name, group) in enumerate(absent_groups): + hex_c = pastels[idx_cat % len(pastels)] + fmt_cat_hl = make_fmt(bg_color=hex_c, bold=True, align="left") + fmt_cat_hr = make_fmt(bg_color=hex_c, bold=True, align="right") + fmt_cat_rl = make_fmt(bg_color=hex_c, bold=False, align="left") + fmt_cat_rr = make_fmt(bg_color=hex_c, bold=False, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, cat_name, fmt_cat_hl) + ws.write(current_row, 1, len(group), fmt_cat_hr) current_row += 1 for fio in sorted(group['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, cat_fill, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) + ws.write(current_row, 0, fio, fmt_cat_rl) + ws.write(current_row, 1, "", fmt_cat_rr) current_row += 1 - # 6. ИТОГО НА РАБОТЕ (Строго физически пришедшие в офис по СКУД) - present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] + # 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок) + exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))] + present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] + + total_present_count = len(present_scud) + len(exc_without_doc) - ws.cell(row=current_row, column=1, value="Итого на работе") - 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) + fmt_pres_hl = make_fmt(bg_color='#E2EFDA', bold=True, align="left") + fmt_pres_hr = make_fmt(bg_color='#E2EFDA', bold=True, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "Итого на работе", fmt_pres_hl) + ws.write(current_row, 1, total_present_count, fmt_pres_hr) current_row += 1 - if not present.empty: - for fio in sorted(present['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True - current_row += 1 - - # 6.1. В ТОМ ЧИСЛЕ НА УДАЛЕННОЙ РАБОТЕ (Удаленщики из дома, которых нет в офисе) + # 5. Удаленная работа (Свернуто) remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] + fmt_rem_hl = make_fmt(bg_color='#E8F8F5', bold=True, align="left") + fmt_rem_hr = make_fmt(bg_color='#E8F8F5', bold=True, align="right") + fmt_rem_rl = make_fmt(bg_color='#E8F8F5', bold=False, align="left") + fmt_rem_rr = make_fmt(bg_color='#E8F8F5', bold=False, align="right") - ws.cell(row=current_row, column=1, value="В том числе на удаленной работе") - 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) + ws.set_row(current_row, 20) + ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl) + ws.write(current_row, 1, len(remote_home), fmt_rem_hr) current_row += 1 if not remote_home.empty: for fio in sorted(remote_home['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) + ws.write(current_row, 0, fio, fmt_rem_rl) + ws.write(current_row, 1, "", fmt_rem_rr) current_row += 1 - # 7. АНОМАЛИИ СКУД И 1С + # 6. Аномалии СКУД и 1С (Свернуто) anomalies = merged_df[ (~is_exc) & ( ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & @@ -2201,14 +2503,17 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') ) ] - - ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") - ws.cell(row=current_row, column=2, value=len(anomalies)) - format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) + fmt_anom_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left") + fmt_anom_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right") + fmt_anom_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left") + fmt_anom_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="left", wrap=True) + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl) + ws.write(current_row, 1, len(anomalies), fmt_anom_hr) current_row += 1 chars_per_line_b = 30 - if not anomalies.empty: for _, row in anomalies.iterrows(): fio = row.get('Сотрудник', '') @@ -2220,92 +2525,67 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})" else: reason_text = f"В 1С: {reason}" - - cell_a = ws.cell(row=current_row, column=1, value=f"{fio}") - cell_b = ws.cell(row=current_row, column=2, value=reason_text) - - cell_a.fill = FILL_ANOMALY - cell_b.fill = FILL_ANOMALY - apply_borders_to_cell(cell_a) - apply_borders_to_cell(cell_b) - - cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) - cell_a.alignment = Alignment(horizontal="left", vertical="center") - - if len(reason_text) > chars_per_line_b: - lines_count = math.ceil(len(reason_text) / chars_per_line_b) - ws.row_dimensions[current_row].height = max(lines_count * 18, 22) - else: - ws.row_dimensions[current_row].height = 20 - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True + lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1 + row_h = max(lines_count * 18, 20) + + ws.set_row(current_row, row_h, None, {'level': 1, 'hidden': True, 'collapsed': True}) + ws.write(current_row, 0, fio, fmt_anom_rl) + ws.write(current_row, 1, reason_text, fmt_anom_rr) current_row += 1 - # ⭐️ 8. РАЗДЕЛ: ИСКЛЮЧЕНИЯ (В самом низу, раскрывающийся список) - exceptions_df = merged_df[is_exc] - - ws.cell(row=current_row, column=1, value="Исключения") - 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) - current_row += 1 - - if not exceptions_df.empty: - for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True - current_row += 1 - - ws.column_dimensions['A'].width = 45.0 - ws.column_dimensions['B'].width = 38.0 - - try: - wb.save(output_path) - print(f"[✓] Ежедневная сводка сохранена: {output_path}") - except PermissionError: - alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") - alt_path = os.path.join(target_dir, alt_filename) - wb.save(alt_path) - print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") + ws.set_column(0, 0, 45) + ws.set_column(1, 1, 38) + + safe_close_workbook(wb, output_path, target_dir, filename) -# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- +# ============================================================================= +# 2. ДЕТАЛЬНЫЙ СУТОЧНЫЙ ОТЧЕТ ЗА ВЧЕРА +# ============================================================================= def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): + date_clean = str(date_str).replace('_', '.') 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: df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() else: 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) - wb = Workbook() - ws = wb.active - ws.title = "Детальный_отчет" - ws["B2"] = "Дата:" - ws["D2"] = date_str - ws["B2"].font = Font(name="Arial", size=10, bold=True) - ws["D2"].font = Font(name="Arial", size=10, bold=True) + wb = xlsxwriter.Workbook(output_path) + ws = wb.add_worksheet("Детальный_отчет") + + def make_fmt(bg_color=None, bold=False, align="left", wrap=False): + d = { + 'font_name': 'Arial', + 'font_size': 10, + 'bold': bold, + 'align': align, + 'valign': 'vcenter', + 'border': 1, + 'border_color': '#D3D3D3', + 'text_wrap': wrap + } + if bg_color: + d['bg_color'] = bg_color + return wb.add_format(d) + + fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True}) + ws.write(1, 1, "Дата:", fmt_date_lbl) + ws.write(1, 3, date_clean, fmt_date_lbl) headers = [ "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "находился в здании", "причина отсутствия", "норма", "отклонение от нормы" ] - ws.append([]) - ws.append(headers) - - header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") - for col_idx in range(1, len(headers) + 1): - cell = ws.cell(row=4, column=col_idx) - cell.fill = header_fill - cell.font = Font(name="Arial", size=10, bold=True) - cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) - apply_borders_to_cell(cell) + fmt_hdr = make_fmt(bg_color='#D9E1F2', bold=True, align="center", wrap=True) + ws.set_row(3, 26) + for col_idx, h_text in enumerate(headers): + ws.write(3, col_idx, h_text, fmt_hdr) start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня' end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня' @@ -2314,90 +2594,125 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): chars_per_line_h = 24 for idx, row in df_export.reset_index(drop=True).iterrows(): + row_num = 4 + idx is_present = row.get('Пришел', False) absence_reason = row.get('Вид_отсутствия', '') has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' - anom_flag = row.get('anomaly_flag', 'NONE') + in_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')) first_act_val = str(row.get('Первая_активность', '—')).strip() has_first_act = first_act_val not in ['—', '', 'None', 'nan'] - dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) - deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) - - ws.append([ - idx + 1, - row.get('Сотрудник', ''), - dept_scud_val, - row.get(start_col, 'Нет входа'), - first_act_val, - row.get(end_col, 'Нет выхода'), - in_building_str, - absence_reason if has_reason else '', - 8, - deviation_val - ]) - - row_num = 5 + idx - - if is_present and has_reason: - row_fill = GREEN_FILL - elif not is_present and has_reason: - row_fill = YELLOW_FILL - elif not is_present and not has_reason and not has_first_act: - row_fill = LIGHT_RED_FILL + 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: - row_fill = None + 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('Подразделение', ''))) + + row_color = None + if is_present and has_reason: + row_color = '#E2EFDA' + elif not is_present and has_reason: + row_color = '#FFF2CC' + elif not is_present and not has_reason and not has_first_act: + row_color = '#FCE4D6' val_h_str = str(absence_reason) if has_reason else "" - if len(val_h_str) > chars_per_line_h: - needed_lines = math.ceil(len(val_h_str) / chars_per_line_h) - ws.row_dimensions[row_num].height = max(needed_lines * 18, 22) - else: - ws.row_dimensions[row_num].height = 20 + lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1 + ws.set_row(row_num, max(lines_count * 18, 20)) - for col_idx in range(1, len(headers) + 1): - cell = ws.cell(row=row_num, column=col_idx) - apply_borders_to_cell(cell) - if row_fill: - cell.fill = row_fill - - if col_idx == 8: - cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) - elif col_idx in [1, 4, 5, 6, 7, 9, 10]: - cell.alignment = Alignment(horizontal="center", vertical="center") - else: - cell.alignment = Alignment(horizontal="left", vertical="center") + values = [ + (idx + 1, 'center', False), + (row.get('Сотрудник', ''), 'left', False), + (dept_scud_val, 'center', False), + (in_val, 'center', False), + (first_act_val, 'center', False), + (out_val, 'center', False), + (in_building_str, 'center', False), + (absence_reason if has_reason else '', 'left', True), + (8, 'center', False), + (deviation_val, 'center', False) + ] - for col in ws.columns: - col_letter = get_column_letter(col[0].column) - max_len = 0 - for cell in col: - if cell.value is not None: - cell_lines = str(cell.value).split("\n") - line_max = max(len(line) for line in cell_lines) - if line_max > max_len: - max_len = line_max + for col_idx, (val, align_type, is_wrap) in enumerate(values): + fmt = make_fmt(bg_color=row_color, align=align_type, wrap=is_wrap) + ws.write(row_num, col_idx, val, fmt) - optimal_width = max(max_len + 2, 8) - if optimal_width > 35: - optimal_width = 35 - ws.column_dimensions[col_letter].width = optimal_width + col_widths = { + 0: 4, + 1: 33, + 2: 13, + 3: 11, + 4: 11, + 5: 11, + 6: 12, + 7: 24, + 8: 6, + 9: 11 + } + + for col_idx, width in col_widths.items(): + ws.set_column(col_idx, col_idx, width) - try: - wb.save(output_path) - print(f"[✓] Детальный отчет сохранен: {output_path}") - except PermissionError: - alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") - alt_path = os.path.join(target_dir, alt_filename) - wb.save(alt_path) - print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") + safe_close_workbook(wb, output_path, target_dir, filename) +# ============================================================================= +# 3. СЫРОЙ СКУД +# ============================================================================= def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): output_path = os.path.join(REPORTS_DIR, filename) - df_scud.to_excel(output_path, index=False) + target_dir = os.path.dirname(output_path) + wb = xlsxwriter.Workbook(output_path) + ws = wb.add_worksheet("Сырые_данные") + + fmt_hdr = wb.add_format({ + 'font_name': 'Calibri', + 'font_size': 11, + 'bold': True, + 'bg_color': '#D9E1F2', + 'border': 1, + 'border_color': '#D3D3D3', + 'align': 'center', + 'valign': 'vcenter' + }) + fmt_cell = wb.add_format({ + 'font_name': 'Calibri', + 'font_size': 11, + 'border': 1, + 'border_color': '#D3D3D3', + 'valign': 'vcenter', + 'align': 'left' + }) + + headers = list(df_scud.columns) + ws.set_row(3, 28) + for col_idx, header in enumerate(headers): + ws.write(0, col_idx, str(header), fmt_hdr) + + col_widths = [len(str(h)) for h in headers] + + for row_idx, row_values in enumerate(df_scud.values, start=1): + ws.set_row(row_idx, 19) + for col_idx, val in enumerate(row_values): + if pd.isna(val) or val is None: + val_str = "" + elif isinstance(val, bool): + val_str = "Да" if val else "Нет" + else: + val_str = str(val) + + ws.write(row_idx, col_idx, val_str, fmt_cell) + if len(val_str) > col_widths[col_idx]: + col_widths[col_idx] = len(val_str) + + for col_idx, width in enumerate(col_widths): + ws.set_column(col_idx, col_idx, min(max(width + 3, 10), 45)) + + safe_close_workbook(wb, output_path, target_dir, filename) ``` ## File: `./services/text_reporter.py` @@ -2409,44 +2724,36 @@ from services.ai_verifier import ask_ollama from services.knowledge_base import load_knowledge_base -def find_python_fuzzy_matches(unexplained_df, raw_absent_df): - if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: +def find_ai_identity_suggestions(unexplained_df, raw_staff_df): + """ + ⭐️ Теневой ИИ-арбитраж: ищет потенциальные опечатки операторов СКУД + среди нераспознанных сотрудников и формирует рекомендации для администратора. + """ + if unexplained_df.empty or raw_staff_df is None or raw_staff_df.empty: return [] - absent_dict = {} - for idx, r in raw_absent_df.iterrows(): - raw_fio = str(r.get('ФИО', '')) - clean_fio = str(r.get('fio_clean', '')) - reason = str(r.get('Вид_отсутствия', '')) - if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']: - absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason} + staff_fios = raw_staff_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in raw_staff_df.columns else [] + unexp_fios = unexplained_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in unexplained_df.columns else [] - matches = [] - unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else [] - - for fio in unexplained_fios: - fio_parts = fio.split() - if not fio_parts: + suggestions = [] + for scud_fio in unexp_fios: + f_parts = scud_fio.split() + if not f_parts: continue - surname = fio_parts[0].lower() + surname = f_parts[0].lower() - for abs_clean, abs_info in absent_dict.items(): - abs_parts = abs_clean.split() - if not abs_parts: - continue - abs_surname = abs_parts[0].lower() - - if surname == abs_surname: - ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio() - if ratio >= 0.75: - matches.append({ - "target_fio": fio, - "found_in_1c_raw": abs_info['raw_fio'], - "reason_1c": abs_info['reason'] + for staff_fio in staff_fios: + if staff_fio.lower().startswith(surname[:4]): + ratio = difflib.SequenceMatcher(None, scud_fio.lower(), staff_fio.lower()).ratio() + if 0.75 <= ratio < 1.0: + suggestions.append({ + "scud_fio": scud_fio, + "suggested_zup_fio": staff_fio, + "confidence": round(ratio, 2) }) 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"): @@ -2455,22 +2762,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 "Специфических правил компании пока нет." total_staff = len(merged_df) - - # 1. Пришедшие в офис present_office_cnt = len(merged_df[merged_df['Пришел'] == True]) - # 2. Не пришедшие удаленщики reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False) 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('Исключение'))]) - unexplained_cnt = len(absent_unexplained) 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 = [] if anomalies_list: @@ -2510,18 +2813,18 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc {anomalies_text_block} --- -⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт): -{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)} +💡 ПРЕДЛОЖЕНИЯ ИИ ПО СОПОСТАВЛЕНИЮ ФИО ({len(ai_suggestions)} шт): +{json.dumps(ai_suggestions, ensure_ascii=False, indent=2)} --- 📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): {json.dumps(unexplained_list, ensure_ascii=False, indent=2)} -СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ: -1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". Если аномалий нет, напиши: "Аномалий не обнаружено." -2. В разделе "Подозрения на ошибки сопоставления ФИО" используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если он пуст, напиши: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." -3. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. -4. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы без перечисления конкретных ФИО. +СТРОГИЕ ИНСТРУКЦИИ: +1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". +2. В разделе "Подозрения на ошибки сопоставления ФИО" выведи рекомендации по сопоставлению из массива "ПРЕДЛОЖЕНИЯ ИИ". Если массив пуст — напиши "Ошибок сопоставления ФИО не обнаружено." +3. В разделе "Неизвестные случаи" выведи нумерованный список всех {unexplained_cnt} человек. +4. В разделе "Рекомендации" дай 2-3 системные рекомендации. СТРОГИЙ ШАБЛОН ОТВЕТА: @@ -2535,19 +2838,19 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc - Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. #### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): -(Выведи нумерованный текстовый список аномалий или 'Аномалий не обнаружено.') +(Список аномалий или 'Аномалий не обнаружено.') -#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: -(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива ошибок ФИО. Если пуст — 'Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.') +#### 💡 Подозрения на ошибки сопоставления ФИО и предложения ИИ: +(Список предложений сопоставления или 'Ошибок сопоставления ФИО не обнаружено.') #### Неизвестные случаи: {unexplained_cnt} -(Выведи нумерованный список всех {unexplained_cnt} человек) +(Нумерованный список всех {unexplained_cnt} человек) ### Точечные рекомендации: (2-3 системные рекомендации) """ - sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке обычным форматированным текстом. Категорически запрещено выводить фигурные скобки JSON или технический код." + sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке форматированным текстом. Запрещено выводить фигурные скобки JSON." return ask_ollama(prompt, system_prompt=sys_prompt) ``` @@ -3144,62 +3447,67 @@ def register_department_synonym(short_name: str, full_name: str) -> None: """ =============================================================================== FILE: services/scud_etl/pipeline.py -ROLE: Выборка данных СКУД из SQLite (scud_logs) с жестким приоритетом Y-снапшотов. =============================================================================== """ import os import logging +from typing import Optional, Dict, Any, Tuple import pandas as pd + 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") -def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame: - """Извлекает срез СКУД. Для вчерашнего дня строго берет финальный вечерний срез Y.""" +def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) -> Optional[pd.DataFrame]: + """ + Загружает наилучший срез СКУД за дату. + Если prefer_final_y=True — отдает предпочтение финишному Y (23:59:59). + """ with get_connection() as conn: - df = pd.DataFrame() cursor = conn.cursor() + target_snap_id = None if prefer_final_y: - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", - (date_str,) - ) + cursor.execute(""" + SELECT snapshot_id + 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() - if row and row[0]: - df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) + if row: + target_snap_id = row[0] - if df.empty: - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", - (date_str,) - ) + if not target_snap_id: + cursor.execute(""" + SELECT snapshot_id + FROM scud_logs + WHERE log_date = ? + ORDER BY id DESC LIMIT 1 + """, (date_str,)) row = cursor.fetchone() - if row and row[0]: - df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) + if row: + target_snap_id = row[0] - if not df.empty: - rename_map = { - '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) + if not target_snap_id: + return None - 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]: - 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(), - df_absences if df_absences is not None else pd.DataFrame()) +def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]: + """ + Загружает реестры штата и отсутствий 1С на указанную дату через data_loader. + """ + df_staff = load_staff_data(date_str) + df_abs = load_absent_data(date_str) + return df_staff, df_abs ``` ## File: `./services/scud_etl/merger.py` @@ -3207,19 +3515,134 @@ def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]: """ =============================================================================== FILE: services/scud_etl/merger.py +ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки. + Распределение исключений в общий рабочий пул при отсутствии справок. =============================================================================== """ import logging -from typing import Dict, Any +from typing import Dict, Any, List import pandas as pd from services.knowledge.service import get_department_synonyms_dict from config import normalize_fio, load_exceptions +from core.connection import get_connection logger = logging.getLogger("SCUD_MERGER") +def load_identity_mappings() -> Dict[str, str]: + 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: + 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( df_scud: pd.DataFrame, df_staff_1c: pd.DataFrame, @@ -3235,7 +3658,10 @@ def merge_scud_and_1c( synonyms = get_department_synonyms_dict() 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: df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio) @@ -3256,7 +3682,6 @@ def merge_scud_and_1c( if col not in df_res.columns: df_res[col] = default_val - # 1. Приведение отделов к аббревиатурам reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()} direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()} all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"} @@ -3266,9 +3691,8 @@ def merge_scud_and_1c( lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) ) - # 2. Привязка документов отсутствий из 1С ПЕРЕД разметкой исключений 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) reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None) @@ -3282,7 +3706,6 @@ def merge_scud_and_1c( df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) df_res["Вид_отсутствия"] = df_res["причина отсутствия"] - # 3. Разметка исключений 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_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] @@ -3292,20 +3715,23 @@ def merge_scud_and_1c( df_res["is_excluded"] = False for idx, row in df_res.iterrows(): 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: df_res.at[idx, "is_excluded"] = False continue dep = str(row.get("Подразделение", "")).upper() pos = str(row.get("Должность", "")).lower() - 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 + is_match_exc = (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 is_match_exc: + if has_official_absence: + df_res.at[idx, "is_excluded"] = False + else: + df_res.at[idx, "is_excluded"] = True - # Чистое "Исключение" только для истинно исключенных без документов 1С mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == "")) df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение" df_res.loc[mask_exc, "причина отсутствия"] = "Исключение" @@ -3314,32 +3740,30 @@ def merge_scud_and_1c( def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: - active_df = df_merged[df_merged.get("is_excluded", False) == False].copy() - total_staff = len(active_df) + total_staff = len(df_merged) - # 1. Строго физически пришедшие в офис - came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа") - working_in_office = active_df[came_to_office_mask] - working_in_office_count = len(working_in_office) + came_to_office_mask = (df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (df_merged.get("is_excluded", False) == False) + exc_without_doc_mask = (df_merged.get("is_excluded", False) == True) & ( + df_merged["Вид_отсутствия"].isna() | + df_merged["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"]) + ) - # 2. Не пришедшие в офис - df_not_came = active_df[~came_to_office_mask] + working_in_office_count = len(df_merged[came_to_office_mask | exc_without_doc_mask]) - # 3. Из не пришедших — удаленщики (работают из дома) - reason_series = df_not_came["причина отсутствия"].astype(str).str.lower() + df_not_working = df_merged[~came_to_office_mask & ~exc_without_doc_mask] + + reason_series = df_not_working["причина отсутствия"].astype(str).str.lower() is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) - remote_home = df_not_came[is_remote_mask] + remote_home = df_not_working[is_remote_mask] remote_home_count = len(remote_home) - # 4. Из оставшихся — официальные отсутствия (отпуск, больничный, командировка) - df_remaining_absent = df_not_came[~is_remote_mask] + df_remaining_absent = df_not_working[~is_remote_mask] has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ df_remaining_absent["причина отсутствия"].ne("") & \ df_remaining_absent["причина отсутствия"].ne("nan") & \ (~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение")) official_absent_count = len(df_remaining_absent[has_doc_mask]) - # 5. Неизвестные unknown = df_remaining_absent[~has_doc_mask] unknown_count = len(unknown) @@ -3358,32 +3782,44 @@ def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: """ =============================================================================== FILE: services/scud_etl/anomaly_detector.py -PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: services / scud_etl -ROLE: Детектирование истинных аномалий и конфликтов реестров. - (Сотрудники из exceptions.json, удаленка и командировки исключены). +ROLE: Детектирование аномалий СКУД, дубликатов пропусков и несоответствий с 1С. =============================================================================== """ import logging from typing import List, Dict, Any import pandas as pd +from config import normalize_fio 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 = [] if df_merged is None or df_merged.empty: 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(): - # Пропускаем системные исключения if row.get("is_excluded", False): continue @@ -3393,25 +3829,24 @@ def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]: reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip() reason_lower = reason.lower() - # Пропускаем технические метки if "исключен" in reason_lower or "овк" in reason_lower: continue - # ⭐️ Физический приход в офис: удаленка и командировки разрешены и НЕ являются аномалией + # Приход в офис во время отпуска/больничного (удаленка и командировки разрешены) 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): anomalies.append({ "fio": fio, "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"]: anomalies.append({ "fio": fio, "type": "SCUD_EQUIPMENT_ANOMALY", - "description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})." + "description": f"Зафиксирован выход ({end_day}) при отсутствии отметки утреннего входа." }) return anomalies @@ -3495,15 +3930,6 @@ def delete_snapshots_safely(snapshot_ids: List[str]) -> Dict[str, Any]: """ =============================================================================== FILE: services/tasks/repository.py -PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: services / tasks -ROLE: Низкоуровневые операции к таблице tasks в SQLite (CRUD). - -AI-CONTEXT-ANCHORS: - - ANCHOR[TASK_REPO_GET]: Выборка задач с фильтрацией по статусу и пользователю. - - ANCHOR[TASK_REPO_ADD]: Вставка новой задачи со сквозным ID. - - ANCHOR[TASK_REPO_UPDATE]: Обновление реквизитов и статуса задачи. - - ANCHOR[TASK_REPO_DELETE]: Удаление задачи по числовому или строковому ID. =============================================================================== """ @@ -3513,7 +3939,6 @@ from core.connection import get_connection def normalize_task_id(task_id_input: str) -> str: - """Нормализует идентификатор задачи к формату TASK-XX.""" if not task_id_input: return "" clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "") @@ -3523,41 +3948,36 @@ def normalize_task_id(task_id_input: str) -> str: return f"TASK-{clean_id}" -# ANCHOR[TASK_REPO_GET] def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]: - """Получает список задач пользователя с опциональной фильтрацией по статусу.""" - conn = get_connection(row_factory=True) - cursor = conn.cursor() - - if status and status.upper() != "ALL": - target_status = status.upper() - if target_status in ["PROGRESS", "В РАБОТЕ"]: - target_status = "IN_PROGRESS" - elif target_status in ["DONE", "ГОТОВО"]: - target_status = "COMPLETED" - elif target_status in ["PLANNED", "ПЛАНЫ"]: - target_status = "BACKLOG" - - cursor.execute(""" - SELECT id, task_id, module, title, priority, status, due_date, created_at - FROM tasks - WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED')) - ORDER BY id DESC - """, (user_id, target_status, target_status)) - else: - cursor.execute(""" - SELECT id, task_id, module, title, priority, status, due_date, created_at - FROM tasks - WHERE user_id = ? - ORDER BY id DESC - """, (user_id,)) - - rows = cursor.fetchall() - conn.close() - return [dict(r) for r in rows] + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + if status and status.upper() != "ALL": + target_status = status.upper() + if target_status in ["PROGRESS", "В РАБОТЕ"]: + target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: + target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ"]: + target_status = "BACKLOG" + + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED')) + ORDER BY id DESC + """, (user_id, target_status, target_status)) + else: + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? + ORDER BY id DESC + """, (user_id,)) + + rows = cursor.fetchall() + return [dict(r) for r in rows] -# ANCHOR[TASK_REPO_ADD] def repo_add_task( user_id: int, module: str, @@ -3566,13 +3986,6 @@ def repo_add_task( due_date: Optional[str] = None, status: str = "BACKLOG" ) -> Dict[str, Any]: - """Добавляет новую задачу в SQLite с автогенерацией порядкового TASK-ID.""" - conn = get_connection() - cursor = conn.cursor() - cursor.execute("SELECT MAX(id) FROM tasks") - max_id = cursor.fetchone()[0] or 0 - new_task_id = f"TASK-{(max_id + 1):02d}" - target_status = status.upper() if status else "BACKLOG" if target_status in ["PROGRESS", "В РАБОТЕ"]: target_status = "IN_PROGRESS" @@ -3581,16 +3994,20 @@ def repo_add_task( elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]: target_status = "BACKLOG" - cursor.execute(""" - INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id)) - conn.commit() - conn.close() - return {"status": "success", "task_id": new_task_id, "id": max_id + 1} + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT MAX(id) FROM tasks") + max_id = cursor.fetchone()[0] or 0 + new_task_id = f"TASK-{(max_id + 1):02d}" + + cursor.execute(""" + INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id)) + conn.commit() + return {"status": "success", "task_id": new_task_id, "id": max_id + 1} -# ANCHOR[TASK_REPO_UPDATE] def repo_update_task( user_id: int, task_id: str, @@ -3599,10 +4016,6 @@ def repo_update_task( status: Optional[str] = None, due_date: Optional[str] = None ) -> Dict[str, Any]: - """Комплексное обновление атрибутов задачи.""" - conn = get_connection() - cursor = conn.cursor() - clean_num = re.sub(r'\D', '', str(task_id)) formatted_id = normalize_task_id(task_id) @@ -3633,7 +4046,6 @@ def repo_update_task( params.append(due_date.strip() if due_date.strip() else None) if not updates: - conn.close() return {"status": "success", "message": "Нет данных для обновления"} params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id]) @@ -3642,10 +4054,11 @@ def repo_update_task( SET {', '.join(updates)} WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? """ - cursor.execute(sql, params) - rows_affected = cursor.rowcount - conn.commit() - conn.close() + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute(sql, params) + rows_affected = cursor.rowcount + conn.commit() if rows_affected == 0: return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} @@ -3653,22 +4066,18 @@ def repo_update_task( return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"} -# ANCHOR[TASK_REPO_DELETE] def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: - """Удаляет задачу по номеру ID.""" - conn = get_connection() - cursor = conn.cursor() clean_num = re.sub(r'\D', '', str(task_id)) formatted_id = normalize_task_id(task_id) - cursor.execute(""" - DELETE FROM tasks - WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? - """, (clean_num, formatted_id, f"%{task_id.strip()}", user_id)) - - deleted = cursor.rowcount - conn.commit() - conn.close() + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + DELETE FROM tasks + WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (clean_num, formatted_id, f"%{task_id.strip()}", user_id)) + deleted = cursor.rowcount + conn.commit() if deleted == 0: return {"error": f"Задача {task_id} не найдена"} diff --git a/main_etl.py b/main_etl.py index 0a8fa8d..54c6f83 100644 --- a/main_etl.py +++ b/main_etl.py @@ -19,6 +19,7 @@ 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.snapshots.retention import cleanup_old_intermediate_snapshots from services.scud_export import run_export from services.share_copier import copy_1c_files_from_share @@ -27,19 +28,69 @@ from services.excel_exporter import export_raw_scud logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") +def print_help(): + print(""" +=============================================================================== +🛠️ SCUD ORION AI — СИСТЕМА КОНТРОЛЛИНГА И СВОДНЫХ ОТЧЕТОВ +=============================================================================== +Использование: + python main_etl.py [ОПЦИИ] + +Доступные аргументы: + -h, --help, help Показать эту справку и выйти + --date ДД.ММ.ГГГГ Дата расчета (по умолчанию: текущий рабочий день) + --time ЧЧ:ММ Время среза для сводки (например: 14:30) + Ищет ближайший срез (±20 мин) или запрашивает On-Demand экспорт + --snapshot ID Точный ID снапшота для расчета (например: 20260827-002) + --skip-export Пропустить выгрузку СКУД из MS SQL (работать только с SQLite) + --export-only ТОЛЬКО сделать экспорт/снапшот СКУД в БД без построения отчетов + -d, --debug Включить режим расширенной отладки + +Примеры использования: + python main_etl.py + 👉 Полный суточный цикл: экспорт -> отчет за вчера -> сводка за сегодня -> ИИ. + + python main_etl.py --export-only + 👉 Почасовой тихий срез в БД (для cron) без генерации отчетов. + + python main_etl.py --date 27.08.2026 --time 12:15 --skip-export + 👉 Построить сводку за 27.08 на 12:15 без запроса к внешнему MS SQL. + + python main_etl.py --snapshot 20260827-001 --skip-export + 👉 Расчет отчетов строго по выбранному снапшоту из SQLite. +=============================================================================== +""") + + def main(): - parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С") - parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки") - parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL") - 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 снапшота для расчета") + if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"): + print_help() + sys.exit(0) + + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("-h", "--help", action="store_true") + parser.add_argument("-d", "--debug", action="store_true") + parser.add_argument("--skip-export", action="store_true") + parser.add_argument("--export-only", action="store_true") + parser.add_argument("--date", type=str, default=None) + parser.add_argument("--time", type=str, default=None) + parser.add_argument("--snapshot", type=str, default=None) + args = parser.parse_args() + if args.help: + print_help() + sys.exit(0) print("=" * 60) print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}") print("=" * 60) + # Автоматическая ротация архивных почасовых срезов + if not args.skip_export and not args.snapshot: + deleted_count = cleanup_old_intermediate_snapshots(days_to_keep_all=2) + if deleted_count > 0: + print(f"[🧹] Ротация БД: очищено {deleted_count} строк промежуточных архивных срезов.") + now = datetime.now() if args.date: today_str = args.date.replace('_', '.') @@ -60,6 +111,11 @@ def main(): else: print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...") + # Если запрошен режим тихого почасового среза — выходим без тяжелых генераций + if args.export_only: + print(f"\n[✓] Режим --export-only: срез зафиксирован в SQLite. Генерация отчетов пропущена.") + sys.exit(0) + # [Этап 0.5] Синхронизация файлов с шары 1С if not args.snapshot and not args.skip_export: print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...") diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index 575b4c1..97b038a 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -3,19 +3,11 @@ FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm (Core Agent Coordinator) -ROLE: Нативный оркестратор диалога, диспетчер инструментов (Function Calling) - и управление сессионными стейтами через ContextManager. - -AI-CONTEXT-ANCHORS: - - ANCHOR[AGENT_IMPORTS]: Системные и доменные импорты. - - ANCHOR[AGENT_MAIN_PIPELINE]: Основная точка входа process_chat_message. - - ANCHOR[AGENT_SYSTEM_PROMPT]: Формирование динамического контекста. - - ANCHOR[AGENT_TOOL_DISPATCHER]: Исполнение нативных вызовов инструментов. - - ANCHOR[AGENT_TOPIC_DRIFT]: Защита контекста и обработка свободных тем. +ROLE: Нативный оркестратор диалога, диспетчер Function Calling, + передача активных срезов в контекст модели и терминальные вызовы. =============================================================================== """ -# ANCHOR[AGENT_IMPORTS] import sys import json import logging @@ -27,7 +19,7 @@ from .db_tools import ( db_apply_prompt_node_action, db_get_tool_action, db_get_tasks, - db_update_task_status, + db_update_task_details, db_delete_task, db_add_task, db_tasks_edit, @@ -70,7 +62,6 @@ if not logger.handlers: logger.addHandler(handler) -# ANCHOR[AGENT_MAIN_PIPELINE] def process_chat_message( user_id: int, user_message: str, @@ -90,7 +81,7 @@ def process_chat_message( full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message - # 1. Быстрый перехват строго системных кнопок UI (подтверждение превью промпта) + # 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) if fast_path_res: return fast_path_res @@ -99,7 +90,6 @@ def process_chat_message( db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) db_history = db_get_chat_history(session_id, limit=20) - # ANCHOR[AGENT_SYSTEM_PROMPT] calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" @@ -111,52 +101,50 @@ def process_chat_message( active_state_context = "" if current_state_type == "PROMPT_PREVIEW": - active_state_context = ( - "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n" - "- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n" - ) - elif current_state_type == "PROMPT_FOLLOWUP": - active_state_context = ( - "\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n" - "- Оператор просматривает или редактирует системный промпт.\n" - "- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n" - "- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n" - "- Запрещено путать ADD и DELETE.\n" - ) + active_state_context = "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n- Открыт предпросмотр изменений промпта.\n" elif current_state_type == "SNAPSHOTS_VIEW": active_date = state_data.get("query_date", "выбранную дату") + active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n" + elif current_state_type == "SNAPSHOT_INSPECT": + snap_id = state_data.get("snapshot_id", "") + snap_date = state_data.get("log_date", "") + records = state_data.get("records", []) + + lines = [] + for r in records: + st = "Присутствовал" if r.get("is_present") else "Отсутствовал" + lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}") + + dump_str = "\n".join(lines) active_state_context = ( - f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n" - f"- Сейчас на экране отображаются снапшоты за {active_date}.\n" - f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n" - f"- Если оператор запрашивает ДРУГУЮ дату или день недели, отличную от {active_date} (например: 'за вчера', 'а за 13.08', 'покажи за сегодня') — " - f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n" - f"- Запрещено генерировать текст за другую дату по памяти.\n" - ) - elif current_state_type == "SNAPSHOT_DELETE_CONFIRM": - active_state_context = ( - "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n" - ) - elif current_state_type == "TASK_DELETE_CONFIRM": - active_state_context = ( - "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n" + f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n" + f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, их времени входа/выхода, отделах или присутствии — " + f"ТЫ ОБЯЗАН брать данные исключительно из этого списка активного среза:\n{dump_str}\n" ) + # ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам system_prompt_content = ( f"Ты — интеллектуальный ассистент SCUD Orion AI.\n" - f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\n\n" + f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n" f"СТРОГИЕ ПРАВИЛА:\n" f"1. К оператору всегда обращайся на Вы.\n" - f"2. Для получения данных всегда вызывай соответствующий инструмент:\n" - f" - Снапшоты и срезы логов СКУД за любые даты и дни недели -> db_get_snapshots\n" - f" - Удаление снапшотов -> db_delete_snapshots\n" - f" - Задачи и бэклог (просмотр, создание, смена статуса, удаление, экспорт) -> db_get_tasks, db_tasks_edit\n" + f"2. Для работы с данными системы ВСЕГДА вызывай соответствующие инструменты (tools):\n" + f" - Снапшоты и срезы СКУД -> db_get_snapshots\n" + f" - Удаление срезов -> db_delete_snapshots\n" + f" - Задачи и бэклог -> db_get_tasks, db_tasks_edit\n" f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n" + f" - База знаний и регламенты -> db_get_rules\n" f" - Аномалии СКУД/1С -> db_get_anomalies\n" - f" - База знаний -> db_get_rules\n" - f" - Статистика БД -> db_get_stats\n" f" - Справка -> db_get_reference\n" - f"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n" + f"3. ЗАДАЧИ:\n" + f" - При любых запросах на просмотр задач (включая опечатки вроде 'змдачи', 'таски', 'дела', 'покажи задачи') — " + f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n" + f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! " + f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n" + f"4. ПРАВИЛА И РЕГЛАМЕНТЫ: При любых вопросах о правилах компании или арбитраже ТЫ ОБЯЗАН СРАЗУ вызвать db_get_rules.\n" + f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n" + f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы " + f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n" f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" f"- Пользователь: {user_info}\n" f"- {calendar_context}\n" @@ -165,7 +153,6 @@ def process_chat_message( user_msg_object = {"role": "user", "content": full_user_content} - # ANCHOR[AGENT_TOOL_DISPATCHER] try: if image_b64: user_msg_object["images"] = [image_b64] @@ -201,7 +188,7 @@ def process_chat_message( logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - # Ротация контекста: закрываем старую сессию инструмента + # Ротация контекста close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}") session_state = None mark_last_user_message_ephemeral(session_id) @@ -217,7 +204,7 @@ def process_chat_message( } # 2. Единый диспетчер задач - elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]: + elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]: action = fn_args.get("action", "UPDATE").upper() if fn_name == "db_add_task": action = "ADD" elif fn_name == "db_delete_task": action = "DELETE" @@ -239,7 +226,7 @@ def process_chat_message( elif action == "EXPORT": export_res = db_export_tasks_markdown( user_id=user_id, - filename=fn_args.get("filename"), + filename=fn_args.get("filename") or "ROADMAP.md", status_filter=fn_args.get("status") ) reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.") @@ -277,96 +264,46 @@ def process_chat_message( # 3. Системный промпт elif fn_name == "db_get_system_prompt": active_prompt = db_get_active_system_prompt() - reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" + reply_text = ( + "📋 **АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ:**\n\n" + f"```text\n{active_prompt}\n```\n\n" + "Вы можете добавить, отредактировать или удалить любой пункт." + ) db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) - return reply_text, db_get_chat_history(session_id), None - - elif fn_name == "db_prompt_node_edit": - action = str(fn_args.get("action", "ADD")).upper() - - try: - sec_id = int(str(fn_args.get("section_id", 3)).strip()) - except Exception: - sec_id = 3 - - try: - itm_id = int(str(fn_args.get("item_id", 1)).strip()) - except Exception: - itm_id = 1 - - content = str(fn_args.get("content", "")).strip() - baseline_prompt = db_get_active_system_prompt() - - with get_db_connection() as temp_conn: - temp_cursor = temp_conn.cursor() - temp_cursor.execute(""" - SELECT section_id, item_id, content - FROM system_prompt_nodes - WHERE prompt_name = 'main_agent' AND is_active = 1 - ORDER BY section_id, item_id - """) - existing_nodes = temp_cursor.fetchall() - - nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes} - nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} if action == "DELETE" else dict(nodes_dict) - if action != "DELETE": - nodes_dict_for_draft[(sec_id, itm_id)] = content - - draft_lines = [] - curr_sec = None - for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): - if i_id == 0: - if curr_sec is not None: draft_lines.append("") - draft_lines.append(f"{s_id}. {txt}") - curr_sec = s_id - else: - draft_lines.append(f" {s_id}.{i_id}. {txt}") - merged_prompt = "\n".join(draft_lines) - - diff_lines = [] - curr_sec = None - display_nodes = dict(nodes_dict) - if action != "DELETE": - display_nodes[(sec_id, itm_id)] = content - - for (s_id, i_id), txt in sorted(display_nodes.items()): - if i_id == 0: - if curr_sec is not None: diff_lines.append("") - diff_lines.append(f"{s_id}. {txt}") - curr_sec = s_id - else: - if s_id == sec_id and i_id == itm_id: - line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' if action == "DELETE" else f' {s_id}.{i_id}. {txt}' - else: - line_str = f" {s_id}.{i_id}. {txt}" - diff_lines.append(line_str) - - diff_html = "\n".join(diff_lines) - - db_set_session_state(session_id, "PROMPT_PREVIEW", { - "draft_text": merged_prompt, - "action": action, - "section_id": sec_id, - "item_id": itm_id, - "content": content, - "idle_turns": 0 - }) - - preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените." - db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) - return preview_reply, db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW", - "raw_draft": merged_prompt, - "baseline_prompt": baseline_prompt, + return reply_text, db_get_chat_history(session_id), { + "type": "PROMPT_VIEW", "buttons": [ - {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, - {"label": "Отменить", "value": "отмена", "style": "danger"}, - {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + {"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"}, + {"label": "База знаний", "value": "покажи правила компании", "style": "secondary"} ] } - # 4. Снапшоты СКУД + # 4. База знаний и правила компании (терминальный возврат) + elif fn_name == "db_get_rules": + rules_data = db_get_rules() + if isinstance(rules_data, dict) and "rules" in rules_data: + rules_list = rules_data["rules"] + elif isinstance(rules_data, list): + rules_list = rules_data + else: + rules_list = [str(rules_data)] + + formatted_rules = "\n\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)]) + reply_text = ( + "📖 **БАЗА ЗНАНИЙ И ПРАВИЛА КОМПАНИИ (КАДРОВЫЙ АРБИТРАЖ):**\n\n" + f"```text\n{formatted_rules}\n```" + ) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "RULES_VIEW", + "buttons": [ + {"label": "✏️ Редактировать правила", "value": "action:open_rules_editor", "style": "primary"}, + {"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"} + ] + } + + # 5. Снапшоты СКУД elif fn_name == "db_get_snapshots": snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) query_date = snapshots_res.get("query_date", "выбранную дату") @@ -422,41 +359,14 @@ def process_chat_message( "type": "SNAPSHOTS_CARD", "data": updated_snapshots_res } - - elif fn_name == "db_delete_snapshots": - raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str") - raw_ids = fn_args.get("snapshot_ids") or ([raw_id] if raw_id else []) - - # Защита: итоговые Y-срезы удалять запрещено - safe_ids = [s for s in raw_ids if not str(s).startswith("Y")] - if not safe_ids: - reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты." - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) - return reply_text, db_get_chat_history(session_id), None - # Прямое выполнение удаления в SQLite без лишних текстовых подтверждений - del_res = db_delete_snapshots(snapshot_ids=safe_ids) - - # Получаем свежий реестр за ту же дату - query_date = fn_args.get("day_str", "") - updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date) - - reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт." - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) - return reply_text, db_get_chat_history(session_id), { - "type": "SNAPSHOTS_CARD", - "data": updated_snapshots_res - } - - # 5. Сервисные запросы + # 6. Прочие сервисные инструменты elif fn_name == "db_get_current_server_time": tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) elif fn_name == "db_get_stats": tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) elif fn_name == "db_get_anomalies": tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) - elif fn_name == "db_get_rules": - tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) elif fn_name == "db_get_reference": tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) else: @@ -468,43 +378,15 @@ def process_chat_message( raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен." - for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]: - if final_content.lower().startswith(artifact): - final_content = final_content[len(artifact):].lstrip(",.!?:; -") - - is_ephem = 1 if fn_name in ["db_get_snapshots", "db_get_system_prompt", "db_prompt_node_edit"] else 0 - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_ephem) + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) return final_content, db_get_chat_history(session_id), None - # ANCHOR[AGENT_TOPIC_DRIFT] + # Свободный диалог (Topic Drift) raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." - - for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]: - if final_reply.lower().startswith(artifact): - final_reply = final_reply[len(artifact):].lstrip(",.!?:; -") - - action_payload = None - - if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]: - idle_turns += 1 - if idle_turns > 3: - db_clear_session_state(session_id) - db_purge_ephemeral_messages(session_id) - action_payload = None - session_state = None - elif idle_turns == 3: - tool_action = db_get_tool_action("db_confirm_prompt_preview") - guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?" - buttons = tool_action.get("buttons", []) if tool_action else [] - final_reply += f"\n\n💡 *Напоминание:* {guard_question}" - action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons} - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns}) - else: - db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns}) db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) - return final_reply, db_get_chat_history(session_id), action_payload + return final_reply, db_get_chat_history(session_id), None except Exception as ex: logger.exception(f"Непредвиденная ошибка агента: {ex}") diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py index b33e1c8..2fa5bd0 100644 --- a/modules/web_api/llm/core/fast_path.py +++ b/modules/web_api/llm/core/fast_path.py @@ -3,42 +3,247 @@ FILE: modules/web_api/llm/core/fast_path.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core -ROLE: Детерминированный мгновенный перехват нажатий кнопок подтверждения - (без задержек LLM и обращения к Ollama). - -AI-CONTEXT-ANCHORS: - - ANCHOR[FAST_PATH_MAIN]: Точка входа handle_fast_path_intercept. +ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью. =============================================================================== """ -import json +import difflib import logging from typing import Dict, Any, Tuple, Optional -# Прямые вызовы чистых доменных сервисов from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt -from services.tasks.service import get_tasks, delete_task +from services.knowledge.service import get_rules, add_rule +from services.tasks.service import get_tasks, delete_task, execute_task_action from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely -# Чат и сессии from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages -from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state, db_get_tool_action +from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup logger = logging.getLogger("FAST_PATH") -# ANCHOR[FAST_PATH_MAIN] +def _generate_prompt_diff_html(baseline_text: str, draft_text: str) -> str: + base_lines = [line.rstrip() for line in baseline_text.strip().splitlines()] + draft_lines = [line.rstrip() for line in draft_text.strip().splitlines()] + + matcher = difflib.SequenceMatcher(None, base_lines, draft_lines) + diff_html_lines = [] + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == 'equal': + for line in base_lines[i1:i2]: + diff_html_lines.append(line) + elif tag == 'delete': + for line in base_lines[i1:i2]: + diff_html_lines.append( + f'{line} [УДАЛЕНИЕ]' + ) + elif tag == 'insert': + for line in draft_lines[j1:j2]: + diff_html_lines.append( + f'{line}' + ) + elif tag == 'replace': + for line in base_lines[i1:i2]: + diff_html_lines.append( + f'{line} [УДАЛЕНИЕ]' + ) + for line in draft_lines[j1:j2]: + diff_html_lines.append( + f'{line}' + ) + + return "\n".join(diff_html_lines) + + def handle_fast_path_intercept( session_id: str, user_message: str, full_user_content: str, session_state: Optional[Dict[str, Any]] ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: - """ - Мгновенный перехват нажатий кнопок подтверждения (Fast-Path). - Возвращает (reply, history, action_type) или None, если требуется передать управление LLM. - """ + msg_raw = user_message.strip() + msg_lower = msg_raw.lower() + + # ------------------------------------------------------------------------- + # 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ) + # ------------------------------------------------------------------------- + if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]): + filename = "ROADMAP.md" + if " в " in msg_lower: + parts = msg_raw.split(" в ", 1)[1].strip().split() + if parts and ("." in parts[0] or parts[0].endswith("md")): + filename = parts[0] + + res = execute_task_action(user_id=1, action="EXPORT", filename=filename) + reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.") + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + + action_payload = { + "type": "FILE_DOWNLOAD_CARD", + "filename": res.get("filename", filename), + "download_url": res.get("download_url", "#"), + "tasks_count": res.get("tasks_count", 0) + } + return reply, db_get_chat_history(session_id), action_payload + + # ------------------------------------------------------------------------- + # 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА) + # ------------------------------------------------------------------------- + if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "): + target_snap_id = msg_raw.split()[-1].replace("#", "").strip() + from core.database import load_scud_from_db_by_snapshot + + df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id) + if df_snap is None or df_snap.empty: + reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных." + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + return reply, db_get_chat_history(session_id), None + + total_cnt = len(df_snap) + present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0 + absent_cnt = total_cnt - present_cnt + + snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—' + log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—' + + rows_list = [] + for _, r in df_snap.iterrows(): + rows_list.append({ + "fio": r.get('Сотрудник', r.get('fio', '')), + "department": r.get('Подразделение', r.get('department_scud', '—')), + "time_in": r.get('Начало_дня', 'Нет входа'), + "first_activity": r.get('Первая_активность', '—'), + "time_out": r.get('Конец_дня', 'Нет выхода'), + "in_building": r.get('Находился_в_здании', '00:00'), + "is_present": bool(r.get('Пришел', False)), + "anomaly": r.get('anomaly_flag', 'NONE') + }) + + # Фиксация среза в памяти сессии для последующих вопросов к LLM + db_set_session_state(session_id, "SNAPSHOT_INSPECT", { + "snapshot_id": target_snap_id, + "log_date": log_date, + "snapshot_time": snap_time, + "records": rows_list, + "idle_turns": 0 + }) + + reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})." + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + + return reply, db_get_chat_history(session_id), { + "type": "SNAPSHOT_INSPECT_CARD", + "snapshot_id": target_snap_id, + "log_date": log_date, + "snapshot_time": snap_time, + "total_count": total_cnt, + "present_count": present_cnt, + "absent_count": absent_cnt, + "records": rows_list + } + + # ------------------------------------------------------------------------- + # 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА + # ------------------------------------------------------------------------- + if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]: + active_prompt = get_active_system_prompt() + state_data = session_state.get("data_json") if session_state else {} + draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": draft_text, + "action": "MANUAL_EDIT", + "idle_turns": 0 + }) + reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:" + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + return reply, db_get_chat_history(session_id), { + "type": "PROMPT_EDITOR", + "raw_draft": draft_text, + "baseline_prompt": active_prompt + } + + # ------------------------------------------------------------------------- + # 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ + # ------------------------------------------------------------------------- + if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]: + rules_data = get_rules() + rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)]) + raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)]) + + db_set_session_state(session_id, "RULES_PREVIEW", { + "draft_text": raw_rules_text, + "action": "MANUAL_EDIT_RULES", + "idle_turns": 0 + }) + reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:" + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + return reply, db_get_chat_history(session_id), { + "type": "RULES_EDITOR", + "raw_draft": raw_rules_text, + "baseline_prompt": raw_rules_text + } + + # ------------------------------------------------------------------------- + # 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ + # ------------------------------------------------------------------------- + if msg_raw.startswith("action:save_draft_prompt:::"): + new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip() + active_prompt = get_active_system_prompt() + diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content) + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": new_draft_content, + "action": "MANUAL_EDIT", + "idle_turns": 0 + }) + + preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените." + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return preview_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": new_draft_content, + "baseline_prompt": active_prompt, + "diff_html": diff_html, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } + + # ------------------------------------------------------------------------- + # 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ + # ------------------------------------------------------------------------- + if msg_raw.startswith("action:save_draft_rules:::"): + new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip() + rules_data = get_rules() + rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)]) + baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)]) + diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content) + + db_set_session_state(session_id, "RULES_PREVIEW", { + "draft_text": new_draft_content, + "action": "MANUAL_EDIT_RULES", + "idle_turns": 0 + }) + + preview_reply = f"Предпросмотр изменений базы знаний компании:\n\n{diff_html}\n\nПодтвердите сохранение или отмените действие." + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return preview_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": new_draft_content, + "baseline_prompt": baseline_rules, + "diff_html": diff_html, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"} + ] + } + if not session_state: return None @@ -47,38 +252,22 @@ def handle_fast_path_intercept( if not isinstance(state_data, dict): state_data = {} - msg_lower = user_message.strip().lower() - # ------------------------------------------------------------------------- - # 1. ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ СИСТЕМНОГО ПРОМПТА + # 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА # ------------------------------------------------------------------------- if state_type == "PROMPT_PREVIEW": is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"] is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"] if is_confirm: - action = state_data.get("action", "MANUAL_EDIT") draft_text = state_data.get("draft_text", "") - - # Применение изменений - if action == "MANUAL_EDIT" and draft_text: + if draft_text: save_full_prompt_draft(draft_text, prompt_name="main_agent") - else: - sec_id = state_data.get("section_id") - itm_id = state_data.get("item_id") - content = state_data.get("content", "") - if sec_id is not None and itm_id is not None: - apply_prompt_action(action=action, section_id=sec_id, item_id=itm_id, content=content) - elif draft_text: - save_full_prompt_draft(draft_text, prompt_name="main_agent") - # Закрытие сессии и зачистка close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY") db_clear_session_state(session_id) - tool_action = db_get_tool_action("db_confirm_prompt_preview") - reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных." - + reply = "✅ Системный промпт успешно сохранен и применен в базе данных." db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) return reply, db_get_chat_history(session_id), None @@ -87,80 +276,40 @@ def handle_fast_path_intercept( close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED") db_clear_session_state(session_id) - tool_action = db_get_tool_action("db_cancel_prompt_preview") - reply = tool_action.get("success_template", "❌ Изменения системного промпта отменены.") if tool_action else "❌ Изменения системного промпта отменены." - + reply = "❌ Изменения системного промпта отменены." db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) return reply, db_get_chat_history(session_id), None # ------------------------------------------------------------------------- - # 2. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ СКУД + # 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ # ------------------------------------------------------------------------- - elif state_type == "SNAPSHOT_DELETE_CONFIRM": - is_confirm = "подтверждаю удаление снапшот" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"] + elif state_type == "RULES_PREVIEW": + is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"] is_cancel = msg_lower in ["отмена", "отменить", "нет"] if is_confirm: - snap_ids = state_data.get("snapshot_ids", []) - query_date = state_data.get("query_date", "") + draft_text = state_data.get("draft_text", "") + lines = [l.strip() for l in draft_text.splitlines() if l.strip()] + for line in lines: + clean_line = line + if line[0].isdigit() and "." in line[:5]: + clean_line = line.split(".", 1)[1].strip() + add_rule(clean_line) - # Безопасное удаление через сервис - del_res = delete_snapshots_safely(snapshot_ids=snap_ids) - - close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOTS_DELETED") - - # Получаем свежий список за ту же дату - updated_data = get_snapshots_registry(date_str=query_date if query_date else None) - db_set_session_state(session_id, "SNAPSHOTS_VIEW", updated_data) - - reply = f"✅ Успешно удалено снапшотов: {len(snap_ids)} шт." - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) - - return reply, db_get_chat_history(session_id), { - "type": "SNAPSHOTS_CARD", - "data": updated_data - } - - elif is_cancel: - close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOT_DELETE_CANCELLED") + close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS") db_clear_session_state(session_id) - - reply = "❌ Удаление снапшотов отменено." + + reply = "✅ База знаний и правила компании успешно сохранены в базе данных." db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) return reply, db_get_chat_history(session_id), None - # ------------------------------------------------------------------------- - # 3. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ - # ------------------------------------------------------------------------- - elif state_type == "TASK_DELETE_CONFIRM": - is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"] - is_cancel = msg_lower in ["отмена", "отменить", "нет"] - - if is_confirm: - task_id = state_data.get("task_id") - delete_task(user_id=1, task_id=str(task_id)) - - close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETED") - db_clear_session_state(session_id) - - raw_tasks = get_tasks(user_id=1) - reply = f"🗑 Задача #{task_id} удалена." - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) - - return reply, db_get_chat_history(session_id), { - "type": "TASK_INTERACTIVE_CARD", - "tasks": raw_tasks - } - elif is_cancel: - close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETE_CANCELLED") + close_tool_session_and_cleanup(session_id, close_reason="RULES_EDIT_CANCELLED") db_clear_session_state(session_id) - reply = "❌ Удаление задачи отменено." + reply = "❌ Изменение правил компании отменено." db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) return reply, db_get_chat_history(session_id), None diff --git a/modules/web_api/llm/core/ollama_client.py b/modules/web_api/llm/core/ollama_client.py index 83dd592..fbe58dc 100644 --- a/modules/web_api/llm/core/ollama_client.py +++ b/modules/web_api/llm/core/ollama_client.py @@ -15,7 +15,7 @@ from typing import Dict, Any, Optional logger = logging.getLogger("OLLAMA_CLIENT") OLLAMA_URL = "http://10.121.17.227:11434/api/chat" -TEXT_MODEL = "qwen2.5:14b-instruct-q8_0" +TEXT_MODEL = "qwen2.5:14b" VISION_MODEL = "qwen2.5vl:7b-q8_0" LLM_OPTIONS = { diff --git a/modules/web_api/llm/core/tool_injector.py b/modules/web_api/llm/core/tool_injector.py index 40381d8..8839f62 100644 --- a/modules/web_api/llm/core/tool_injector.py +++ b/modules/web_api/llm/core/tool_injector.py @@ -3,11 +3,7 @@ FILE: modules/web_api/llm/core/tool_injector.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core -ROLE: Семантический анализ намерений оператора (Intent Classifier) и - детерминированная сборка вызовов инструментов при сбоях нативного Function Calling. - -AI-CONTEXT-ANCHORS: - - ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed. +ROLE: Базовая санитарная очистка вывода и тегов инструментов. =============================================================================== """ @@ -32,107 +28,9 @@ def clean_output(text: str) -> str: return text.strip() if text else "" -# ANCHOR[INTENT_INJECTOR_MAIN] def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ - Гибридный семантический анализатор: - Если модель ответила текстом с сомнениями или пропустила tool_call, - распознает доменное намерение и конструирует синтетический tool_call. + Модель управляется через TOOLS_SCHEMA и системный контекст. + Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP. """ - if existing_tool_calls: - return existing_tool_calls - - msg_clean = user_message.strip().lower() - - # 1. СЕМАНТИКА: Просмотр системного промпта - # Паттерны: "покажи системный промпт", "выведи промпт", "текущий промпт", "какой сейчас системный промпт" - if "промпт" in msg_clean and any(kw in msg_clean for kw in ["покажи", "выведи", "какой", "дай", "текст", "актуальн"]): - logger.info("[IntentInjector] Распознано намерение просмотра системного промпта") - return [{ - "function": { - "name": "db_get_system_prompt", - "arguments": {} - } - }] - - # 2. СЕМАНТИКА: Удаление задач - # Паттерны: "удали задачу 37", "убери 37 задачу", "сотри таску #37", "сними с повестки задачу 37" - if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "сотри", "сними"]) and any(kw in msg_clean for kw in ["задач", "таск"]): - task_match = re.search(r'#?\s*(\d+)', msg_clean) - if task_match: - task_id = task_match.group(1) - logger.info(f"[IntentInjector] Распознано намерение удаления задачи: #{task_id}") - return [{ - "function": { - "name": "db_tasks_edit", - "arguments": {"action": "DELETE", "task_id": task_id} - } - }] - - # 3. СЕМАНТИКА: Управление системным промптом (удаление и мульти-удаление) - # Паттерны: "удали 1.8 и 3.4", "удали пункт 2.3", "вычеркни 1.8, 3.4 из промпта" - if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "вычеркни", "сотри"]) and not any(kw in msg_clean for kw in ["задач", "снапшот", "срез"]): - node_matches = re.findall(r'(\d+)[\.\s]+(\d+)', user_message) - if node_matches: - formatted_nodes = [f"{s}.{i}" for s, i in node_matches] - logger.info(f"[IntentInjector] Распознано намерение удаления узлов промпта: {formatted_nodes}") - return [{ - "function": { - "name": "db_prompt_node_edit", - "arguments": { - "action": "BATCH_DELETE" if len(formatted_nodes) > 1 else "DELETE", - "section_id": int(node_matches[0][0]), - "item_id": int(node_matches[0][1]), - "nodes_list": formatted_nodes, - "content": "" - } - } - }] - - # 4. СЕМАНТИКА: Добавление пункта промпта - # Паттерны: "добавь 3.4 Текст", "добавь пункт 3.4 Текст", "впиши в 3.4 Текст" - if any(kw in msg_clean for kw in ["добавь", "добавить", "впиши", "запиши"]) and not any(kw in msg_clean for kw in ["задач", "таск"]): - add_match = re.search(r'(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)', user_message) - if add_match: - sec_id = int(add_match.group(1)) - itm_id = int(add_match.group(2)) - content = add_match.group(3).strip() - logger.info(f"[IntentInjector] Распознано намерение добавления узла промпта: {sec_id}.{itm_id}") - return [{ - "function": { - "name": "db_prompt_node_edit", - "arguments": { - "action": "ADD", - "section_id": sec_id, - "item_id": itm_id, - "content": content - } - } - }] - - # 5. СЕМАНТИКА: Смена статуса задач - if any(kw in msg_clean for kw in ["в работу", "начни", "стартуй", "за работу"]): - task_match = re.search(r'#?\s*(\d+)', msg_clean) - if task_match: - task_id = task_match.group(1) - logger.info(f"[IntentInjector] Распознано намерение взятия в работу задачи: #{task_id}") - return [{ - "function": { - "name": "db_tasks_edit", - "arguments": {"action": "UPDATE", "task_id": task_id, "status": "IN_PROGRESS"} - } - }] - - if any(kw in msg_clean for kw in ["заверши", "закрой", "готово", "выполнено"]): - task_match = re.search(r'#?\s*(\d+)', msg_clean) - if task_match: - task_id = task_match.group(1) - logger.info(f"[IntentInjector] Распознано намерение закрытия задачи: #{task_id}") - return [{ - "function": { - "name": "db_tasks_edit", - "arguments": {"action": "UPDATE", "task_id": task_id, "status": "COMPLETED"} - } - }] - return existing_tool_calls \ No newline at end of file diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py index c2a5b9b..e707d84 100644 --- a/modules/web_api/llm/db_tools.py +++ b/modules/web_api/llm/db_tools.py @@ -22,6 +22,9 @@ from services.tasks.service import ( delete_task as db_delete_task, execute_task_action as db_tasks_edit ) +# Защитный алиас для обратной совместимости +db_update_task_status = db_update_task_details + from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown from services.tasks.repository import normalize_task_id diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py index dfe1567..d90093c 100644 --- a/modules/web_api/llm/schemas.py +++ b/modules/web_api/llm/schemas.py @@ -69,14 +69,18 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_get_tasks", - "description": "Просмотр реестра задач и бэклога текущего пользователя.", + "description": ( + "Просмотр реестра задач и бэклога текущего пользователя.\n" + "Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n" + "Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}." + ), "parameters": { "type": "object", "properties": { "status": { "type": "string", "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"], - "description": "Опциональный фильтр статуса задач" + "description": "Опциональный фильтр статуса задач (по умолчанию ALL)" } } } diff --git a/modules/web_api/main.py b/modules/web_api/main.py index fc65afd..a0bbe46 100644 --- a/modules/web_api/main.py +++ b/modules/web_api/main.py @@ -32,6 +32,10 @@ from routers.admin import router as admin_router from routers.tasks import router as tasks_router from routers.chat import router as chat_router from routers.files import router as files_router +from routers.exceptions import router as exceptions_router +from routers.snapshots import router as snapshots_router +from routers.remote_workers import router as remote_workers_router +from routers.context import router as context_router # ANCHOR[APP_CONFIG] logging.basicConfig( @@ -61,6 +65,10 @@ app.include_router(admin_router) app.include_router(tasks_router) app.include_router(chat_router) app.include_router(files_router) +app.include_router(exceptions_router) +app.include_router(snapshots_router) +app.include_router(remote_workers_router) +app.include_router(context_router) # ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") @@ -80,6 +88,12 @@ async def favicon(): @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") + + # Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов + forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"] + if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns): + raise HTTPException(status_code=403, detail="Access denied") + target = os.path.join(STATIC_DIR, clean_path) if os.path.isfile(target): diff --git a/modules/web_api/routers/auth.py b/modules/web_api/routers/auth.py index 94ff0b1..f9f63c1 100644 --- a/modules/web_api/routers/auth.py +++ b/modules/web_api/routers/auth.py @@ -72,7 +72,7 @@ def login(req: AuthRequest): username = req.username.strip().lower() conn = get_db() cursor = conn.cursor() - cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) + cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,)) user = cursor.fetchone() conn.close() @@ -82,7 +82,17 @@ def login(req: AuthRequest): is_admin = bool(user["is_admin"]) or (user["username"] == "puh") token = create_access_token(user["id"], user["username"], is_admin) - return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} + # Возвращаем full_name (если не задано — отдаем username) + full_name = user["full_name"] if user["full_name"] else user["username"] + + return { + "status": "success", + "token": token, + "username": user["username"], + "full_name": full_name, + "user_id": user["id"], + "is_admin": is_admin + } @router.post("/change-password") diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py index 256c78b..4fe386c 100644 --- a/modules/web_api/routers/chat.py +++ b/modules/web_api/routers/chat.py @@ -1,22 +1,29 @@ """ =============================================================================== FILE: modules/web_api/routers/chat.py -ROLE: Обработка сообщений веб-чата с поддержкой токенов и гостевого доступа. +ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов, + поддержкой Function Calling, Fast-Path и оптического распознавания OCR. =============================================================================== """ -from fastapi import APIRouter, Header, HTTPException, Request -from pydantic import BaseModel -from typing import Optional, List, Dict, Any +import os +import shutil +import base64 import logging +from typing import Optional, List, Dict, Any -from services.text_reporter import ask_ollama -from services.knowledge_base import load_knowledge_base -from core.database import get_connection +from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form +from pydantic import BaseModel + +from llm.agent import process_chat_message +from config import BASE_DIR logger = logging.getLogger("CHAT_API") router = APIRouter(prefix="/api/v1", tags=["Chat"]) +UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads") +os.makedirs(UPLOAD_TMP_DIR, exist_ok=True) + class ChatMessageRequest(BaseModel): message: str @@ -25,17 +32,11 @@ class ChatMessageRequest(BaseModel): def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int: - """ - Извлекает ID пользователя из Bearer-токена. - Если токен не передан или сессия новая — использует user_id=1 по умолчанию, - не блокируя работу ошибкой 403 Forbidden. - """ if explicit_user_id and explicit_user_id > 0: return explicit_user_id if authorization and authorization.startswith("Bearer "): token = authorization.replace("Bearer ", "").strip() - # Если используется простой токен вида 'user_1' или JWT if token.isdigit(): return int(token) elif token.startswith("dev_token_"): @@ -43,8 +44,6 @@ def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optio return int(token.replace("dev_token_", "")) except ValueError: pass - - # Дефолтный пользователь (гостевой / основной аккаунт) return 1 @@ -59,39 +58,97 @@ async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}") - # Загружаем контекст базы знаний - kb = load_knowledge_base() - rules_text = "\n".join([f"- {r}" for r in kb.get("rules", [])]) - - system_prompt = ( - "Ты — ИИ-ассистент системы кадровой безопасности и контроллинга СКУД Orion AI.\n" - "Отвечай четко, профессионально и на русском языке.\n" - f"Актуальные правила системы:\n{rules_text}" + reply_text, history, action_payload = process_chat_message( + user_id=user_id, + user_message=user_msg, + session_id=session_id ) - try: - reply_text = ask_ollama(user_msg, system_prompt=system_prompt) - - # Сохранение истории в SQLite при необходимости + return { + "status": "success", + "user_id": user_id, + "session_id": session_id, + "response": reply_text, + "action_payload": action_payload + } + + +@router.post("/chat/upload") +async def chat_upload_endpoint( + file: UploadFile = File(...), + message: Optional[str] = Form(""), + session_id: Optional[str] = Form("web_session_main"), + authorization: Optional[str] = Header(None) +): + user_id = resolve_user_id(authorization, 1) + file_path = os.path.join(UPLOAD_TMP_DIR, file.filename) + + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + file_context = "" + image_b64 = None + fn_lower = file.filename.lower() + + # 1. Текстовые форматы + if fn_lower.endswith((".txt", ".csv", ".log", ".md")): try: - with get_connection() as conn: - conn.execute( - "INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?), (?, ?, ?)", - (session_id, "user", user_msg, session_id, "assistant", reply_text) - ) - conn.commit() + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + file_context = f.read(6000) + except Exception as e: + logger.warning(f"Не удалось прочитать текст: {e}") + + # 2. Изображения (прямой OCR) + elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")): + try: + with open(file_path, "rb") as f: + image_b64 = base64.b64encode(f.read()).decode("utf-8") + except Exception as e: + logger.warning(f"Ошибка кодирования картинки в base64: {e}") + + # 3. PDF документы (текстовый слой + рендеринг скана при необходимости) + elif fn_lower.endswith(".pdf"): + # Попытка извлечь встроенный текстовый слой + try: + import pypdf + reader = pypdf.PdfReader(file_path) + extracted = [] + for page in reader.pages: + t = page.extract_text() + if t: + extracted.append(t) + file_context = "\n".join(extracted).strip() except Exception: pass - return { - "status": "success", - "user_id": user_id, - "session_id": session_id, - "response": reply_text - } - except Exception as e: - logger.error(f"Ошибка вызова нейросети: {e}") - return { - "status": "error", - "response": f"⚠️ Ошибка обработки запроса: {str(e)}" - } \ No newline at end of file + # Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR + if len(file_context) < 40: + try: + import fitz # PyMuPDF + doc = fitz.open(file_path) + if len(doc) > 0: + page = doc[0] + pix = page.get_pixmap(dpi=150) + img_bytes = pix.tobytes("png") + image_b64 = base64.b64encode(img_bytes).decode("utf-8") + file_context = "" + except Exception as e: + logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}") + + user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}" + + reply_text, history, action_payload = process_chat_message( + user_id=user_id, + user_message=user_msg, + file_context=file_context, + image_b64=image_b64, + session_id=session_id + ) + + return { + "status": "success", + "user_id": user_id, + "session_id": session_id, + "response": reply_text, + "action_payload": action_payload + } \ No newline at end of file diff --git a/modules/web_api/routers/context.py b/modules/web_api/routers/context.py new file mode 100644 index 0000000..9e34800 --- /dev/null +++ b/modules/web_api/routers/context.py @@ -0,0 +1,50 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/context.py +ROLE: REST API мониторинга состояния сессии и очистки памяти диалога. +=============================================================================== +""" + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional + +from routers.auth import get_current_user +from modules.web_api.llm.db.db_prompts import db_get_session_state, db_clear_session_state +from modules.web_api.llm.db.db_chat import db_get_chat_history, db_purge_ephemeral_messages, db_clear_chat_history + +router = APIRouter(prefix="/api/v1/context", tags=["Context"]) + + +class SessionActionRequest(BaseModel): + session_id: Optional[str] = "web_session_main" + + +@router.get("/state") +def api_get_context_state(session_id: str = "web_session_main", current_user = Depends(get_current_user)): + state = db_get_session_state(session_id) or {} + history = db_get_chat_history(session_id, limit=50) + + ephemeral_count = sum(1 for m in history if dict(m).get("is_ephemeral") == 1) + total_messages = len(history) + + return { + "session_id": session_id, + "active_state": state.get("state_type", "IDLE"), + "state_data": state.get("data_json", {}), + "total_messages": total_messages, + "ephemeral_messages": ephemeral_count + } + + +@router.post("/purge-ephemeral") +def api_purge_ephemeral(req: SessionActionRequest, current_user = Depends(get_current_user)): + purged = db_purge_ephemeral_messages(req.session_id) + return {"status": "success", "purged_count": purged} + + +@router.post("/clear-all") +def api_clear_all_context(req: SessionActionRequest, current_user = Depends(get_current_user)): + db_clear_session_state(req.session_id) + db_clear_chat_history(req.session_id) + return {"status": "success", "message": "Контекст сессии полностью очищен"} \ No newline at end of file diff --git a/modules/web_api/routers/remote_workers.py b/modules/web_api/routers/remote_workers.py new file mode 100644 index 0000000..0c046b3 --- /dev/null +++ b/modules/web_api/routers/remote_workers.py @@ -0,0 +1,157 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/remote_workers.py +ROLE: REST API реестра удаленщиков (CRUD, редактирование сроков, автоочистка). +=============================================================================== +""" + +import os +from datetime import datetime +import pandas as pd +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, List, Dict, Any + +from routers.auth import get_current_user +from config import BASE_DIR, DATA_DIR + +router = APIRouter(prefix="/api/v1/remote-workers", tags=["RemoteWorkers"]) +CSV_PATH = os.path.join(DATA_DIR, "static_reason_workers.csv") + + +def _load_workers() -> List[Dict[str, Any]]: + """Читает CSV, гарантирует структуру колонок и удаляет просроченные записи.""" + if not os.path.exists(CSV_PATH): + return [] + try: + df = pd.read_csv(CSV_PATH, dtype=str, on_bad_lines='skip').fillna("") + for col in ["fio", "department", "reason", "date_from", "date_to"]: + if col not in df.columns: + df[col] = "" + + today_date = datetime.now().date() + valid_workers = [] + has_expired = False + + for row in df.to_dict(orient="records"): + fio = str(row.get("fio", "")).strip() + if not fio: + continue + + d_to_str = str(row.get("date_to", "")).strip() + if d_to_str and d_to_str.lower() not in ["nan", "none", ""]: + try: + d_to = datetime.strptime(d_to_str.replace('_', '.'), "%d.%m.%Y").date() + # Если срок завершился вчера или ранее — запись удаляется из файла + if today_date > d_to: + has_expired = True + continue + except ValueError: + pass + + valid_workers.append(row) + + # Синхронная перезапись файла при обнаружении истекших сроков + if has_expired: + _save_workers(valid_workers) + + return valid_workers + except Exception: + return [] + + +def _save_workers(workers: List[Dict[str, Any]]): + """Сохраняет актуальный список в CSV с сохранением колонок.""" + df = pd.DataFrame(workers) + for col in ["fio", "department", "reason", "date_from", "date_to"]: + if col not in df.columns: + df[col] = "" + os.makedirs(os.path.dirname(CSV_PATH), exist_ok=True) + df.to_csv(CSV_PATH, index=False, encoding="utf-8") + + +class RemoteWorkerItem(BaseModel): + fio: str + department: Optional[str] = "Все" + reason: Optional[str] = "Удаленная работа" + date_from: Optional[str] = "" + date_to: Optional[str] = "" + + +class UpdateDatesRequest(BaseModel): + fio: str + date_from: Optional[str] = "" + date_to: Optional[str] = "" + + +@router.get("") +def api_get_remote_workers(current_user = Depends(get_current_user)): + return {"workers": _load_workers()} + + +@router.post("") +def api_add_remote_worker(item: RemoteWorkerItem, current_user = Depends(get_current_user)): + workers = _load_workers() + fio_clean = item.fio.strip() + if not fio_clean: + raise HTTPException(status_code=400, detail="ФИО не может быть пустым") + + # Если начало не указано — берем сегодня + today_str = datetime.now().strftime("%d.%m.%Y") + date_from = item.date_from.strip() if item.date_from and item.date_from.strip() else today_str + date_to = item.date_to.strip() if item.date_to else "" + + # Проверка на совпадение ФИО (обновление существующей записи) + for w in workers: + if str(w.get("fio", "")).strip().lower() == fio_clean.lower(): + w["department"] = item.department.strip() if item.department else (w.get("department") or "Все") + w["reason"] = item.reason.strip() if item.reason else (w.get("reason") or "Удаленная работа") + w["date_from"] = date_from + w["date_to"] = date_to + _save_workers(workers) + return {"status": "success", "message": "Срок удаленки обновлен", "workers": workers} + + workers.append({ + "fio": fio_clean, + "department": item.department.strip() if item.department else "Все", + "reason": item.reason.strip() if item.reason else "Удаленная работа", + "date_from": date_from, + "date_to": date_to + }) + _save_workers(workers) + return {"status": "success", "workers": workers} + + +@router.put("") +def api_update_worker_dates(req: UpdateDatesRequest, current_user = Depends(get_current_user)): + """Редактирование срока удаленки (продление или сокращение).""" + workers = _load_workers() + target_fio = req.fio.strip().lower() + found = False + + for w in workers: + if str(w.get("fio", "")).strip().lower() == target_fio: + w["date_from"] = req.date_from.strip() if req.date_from is not None else w.get("date_from", "") + w["date_to"] = req.date_to.strip() if req.date_to is not None else w.get("date_to", "") + found = True + break + + if not found: + raise HTTPException(status_code=404, detail="Сотрудник не найден в списке") + + _save_workers(workers) + return {"status": "success", "message": "Сроки успешно изменены", "workers": workers} + + +@router.delete("") +def api_delete_remote_worker(fio: str, current_user = Depends(get_current_user)): + workers = _load_workers() + fio_clean = fio.strip().lower() + initial_len = len(workers) + workers = [w for w in workers if str(w.get("fio", "")).strip().lower() != fio_clean] + + if len(workers) == initial_len: + raise HTTPException(status_code=404, detail="Сотрудник не найден") + + _save_workers(workers) + return {"status": "success", "workers": workers} \ No newline at end of file diff --git a/modules/web_api/routers/snapshots.py b/modules/web_api/routers/snapshots.py new file mode 100644 index 0000000..f244af1 --- /dev/null +++ b/modules/web_api/routers/snapshots.py @@ -0,0 +1,53 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/snapshots.py +ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД. +=============================================================================== +""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, List, Dict, Any + +from routers.auth import get_current_user +from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely +from services.scud_export import run_export + +router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"]) + + +class CreateSnapshotRequest(BaseModel): + date_str: Optional[str] = None + + +class DeleteSnapshotsRequest(BaseModel): + snapshot_ids: List[str] + + +@router.get("") +def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)): + return get_snapshots_registry(date_str=date_str) + + +@router.post("/create") +def api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)): + """Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite.""" + try: + success = run_export(input_date=req.date_str, save_xlsx=True, debug=False) + if not success: + raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион") + + fresh_data = get_snapshots_registry(date_str=req.date_str) + return {"status": "success", "message": "Срез успешно создан", "data": fresh_data} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}") + + +@router.delete("") +def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)): + safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")] + if not safe_ids: + raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления") + + res = delete_snapshots_safely(snapshot_ids=safe_ids) + return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))} \ No newline at end of file diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html index a21f5a3..8ef94a9 100644 --- a/modules/web_api/static/index.html +++ b/modules/web_api/static/index.html @@ -1,165 +1,605 @@ - + - SCUD Orion AI — Управление и Аналитика - + SCUD Orion AI Assistant - - + + - + - - - - -
- -
-
- -
-

- SCUD Orion AI Assistant - Online -

-

Система интеллектуального аудита и контроля СКУД / 1С

-
-
-
- - -
-
- - - - - -
- -
- -
-

- ИИ-Ассистент SCUD Orion AI -

-
- Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи. +
+ + + + + +
+
+ + + +
+
+
+
+ +
+
+
+

SCUD Orion AI Assistant

+ Online +
+

Система интеллектуального аудита и контроля СКУД / 1С

+
- -
-
-
- - -
-
- -
+ + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/web_api/static/js/auth.js b/modules/web_api/static/js/auth.js index 4084332..c87749a 100644 --- a/modules/web_api/static/js/auth.js +++ b/modules/web_api/static/js/auth.js @@ -1,62 +1,75 @@ /** * =============================================================================== - * FILE: static/js/auth.js - * ROLE: Управление сессией пользователя, токенами и корректным выходом (Logout). + * FILE: modules/web_api/static/js/auth.js + * ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков. * =============================================================================== */ +const AUTH_STORAGE_KEY = "scud_auth_token"; +const USERNAME_STORAGE_KEY = "scud_username"; +const FULLNAME_STORAGE_KEY = "scud_full_name"; +const IS_ADMIN_STORAGE_KEY = "scud_is_admin"; +const USER_ID_STORAGE_KEY = "scud_user_id"; + const AuthManager = { getToken() { - let token = localStorage.getItem("auth_token") || localStorage.getItem("token"); - if (!token) { - // Если токена нет — инициализируем рабочий дефолтный токен - token = "dev_token_1"; - localStorage.setItem("auth_token", token); - localStorage.setItem("user_id", "1"); - } - return token; + return localStorage.getItem(AUTH_STORAGE_KEY) || ""; }, getUserId() { - return parseInt(localStorage.getItem("user_id") || "1", 10); + const uid = localStorage.getItem(USER_ID_STORAGE_KEY); + return uid ? parseInt(uid, 10) : 1; + }, + + getUsername() { + return localStorage.getItem(USERNAME_STORAGE_KEY) || ""; + }, + + getFullName() { + return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь"; + }, + + isAdmin() { + return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true"; + }, + + isAuthenticated() { + return Boolean(this.getToken()); + }, + + setSession(token, username, fullName, isAdmin, userId = 1) { + localStorage.setItem(AUTH_STORAGE_KEY, token); + localStorage.setItem(USERNAME_STORAGE_KEY, username); + localStorage.setItem(FULLNAME_STORAGE_KEY, fullName || username); + localStorage.setItem(IS_ADMIN_STORAGE_KEY, String(isAdmin)); + localStorage.setItem(USER_ID_STORAGE_KEY, String(userId)); + localStorage.setItem("scud_api_auth_token", token); + localStorage.setItem("auth_token", token); + }, + + getAuthHeaders() { + const token = this.getToken(); + const headers = { "Content-Type": "application/json" }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + return headers; }, logout() { - console.log("[Auth] Выполняется выход из учетной записи..."); - - // 1. Полная очистка хранилищ браузера - localStorage.removeItem("auth_token"); - localStorage.removeItem("token"); - localStorage.removeItem("user_id"); + console.log("[Auth] Полный выход из системы..."); + localStorage.clear(); sessionStorage.clear(); - // 2. Сброс авторизационных cookies (если присутствуют) document.cookie.split(";").forEach((cookie) => { const eqPos = cookie.indexOf("="); const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`; }); - // 3. Перезагрузка страницы для сброса состояния интерфейса - window.location.reload(); - }, - - init() { - // Привязка клика ко всем элементам с классом или id logout - document.addEventListener("DOMContentLoaded", () => { - const logoutBtns = document.querySelectorAll("#logout-btn, .logout-btn, [data-action='logout']"); - logoutBtns.forEach(btn => { - btn.addEventListener("click", (e) => { - e.preventDefault(); - AuthManager.logout(); - }); - }); - }); + window.location.href = "/"; } }; -// Инициализация при загрузке скрипта -AuthManager.init(); - -// Глобальная доступность функции для onclick в HTML +window.AuthManager = AuthManager; window.logout = () => AuthManager.logout(); \ No newline at end of file diff --git a/modules/web_api/static/js/chat/core.js b/modules/web_api/static/js/chat/core.js index d78d751..7140e66 100644 --- a/modules/web_api/static/js/chat/core.js +++ b/modules/web_api/static/js/chat/core.js @@ -1,67 +1,586 @@ /** * =============================================================================== - * FILE: static/js/chat/core.js - * ROLE: Отправка сообщений в API с токеном и обработка ошибок. + * FILE: modules/web_api/static/js/chat/core.js + * ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, авто-высота инпута (24px), + * надежный расчет скролла вопроса к верху окна, крупный шрифт text-sm. * =============================================================================== */ -async function sendMessage(userMessageText) { - const chatInput = document.getElementById("chat-input"); - const message = userMessageText || (chatInput ? chatInput.value.trim() : ""); - if (!message) return; +let currentAttachedFile = null; - if (chatInput && !userMessageText) { - chatInput.value = ""; +const CHAT_INPUT_STORAGE_KEY = "scud_chat_input_history"; +let chatInputHistory = JSON.parse(localStorage.getItem(CHAT_INPUT_STORAGE_KEY) || "[]"); +let chatHistoryIndex = -1; +let temporaryCurrentInput = ""; + +function saveCommandToHistory(commandText) { + if (!commandText || !commandText.trim()) return; + const cleanCmd = commandText.trim(); + if (cleanCmd.startsWith("action:save_draft_")) return; + + chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd); + chatInputHistory.push(cleanCmd); + if (chatInputHistory.length > 50) chatInputHistory.shift(); + localStorage.setItem(CHAT_INPUT_STORAGE_KEY, JSON.stringify(chatInputHistory)); + chatHistoryIndex = -1; +} + +// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: плавное выравнивание вопроса к верхней границе +function scrollToUserMessageTop() { + const container = document.getElementById("chat-messages-container"); + if (!container) return; + + const userBubbles = container.querySelectorAll(".user-chat-bubble"); + const targetEl = userBubbles[userBubbles.length - 1] || container.lastElementChild; + if (!targetEl) return; + + requestAnimationFrame(() => { + setTimeout(() => { + const containerTop = container.getBoundingClientRect().top; + const targetTop = targetEl.getBoundingClientRect().top; + + const targetScroll = container.scrollTop + (targetTop - containerTop) - 16; + + container.scrollTo({ + top: Math.max(0, targetScroll), + behavior: 'smooth' + }); + }, 50); + }); +} + +function updateInputHeightAndFade(textarea) { + if (!textarea) return; + + if (!textarea.value || textarea.value.trim() === '') { + textarea.style.height = '24px'; + textarea.style.overflowY = 'hidden'; + textarea.style.maskImage = 'none'; + textarea.style.webkitMaskImage = 'none'; + return; } - // Отображаем сообщение пользователя в чате - if (typeof appendMessageToUI === "function") { - appendMessageToUI("user", message); + textarea.style.height = 'auto'; + const minHeight = 24; + const maxHeight = 120; + const currentScrollHeight = textarea.scrollHeight; + + if (currentScrollHeight <= minHeight + 2) { + textarea.style.height = minHeight + 'px'; + textarea.style.overflowY = 'hidden'; + textarea.style.maskImage = 'none'; + textarea.style.webkitMaskImage = 'none'; + } else if (currentScrollHeight > maxHeight) { + textarea.style.height = maxHeight + 'px'; + textarea.style.overflowY = 'auto'; + textarea.style.maskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)'; + textarea.style.webkitMaskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)'; + } else { + textarea.style.height = currentScrollHeight + 'px'; + textarea.style.overflowY = 'hidden'; + textarea.style.maskImage = 'none'; + textarea.style.webkitMaskImage = 'none'; + } +} + +function appendUserMessage(text, filename = null) { + const container = document.getElementById("chat-messages-container"); + if (!container) return; + + const msgId = 'user-msg-' + Date.now(); + let fileBadge = ''; + if (filename) { + fileBadge = ` +
+ + ${escapeHtml(filename)} +
+ `; } - // Подготовка заголовков с гарантированным токеном - const token = (typeof AuthManager !== "undefined") ? AuthManager.getToken() : (localStorage.getItem("auth_token") || "dev_token_1"); - const userId = (typeof AuthManager !== "undefined") ? AuthManager.getUserId() : parseInt(localStorage.getItem("user_id") || "1", 10); - const sessionId = localStorage.getItem("chat_session_id") || "web_session_main"; + const msgHtml = ` +
+
+ ${fileBadge} +
${escapeHtml(text)}
+
+
+ +
+
+ `; + container.insertAdjacentHTML("beforeend", msgHtml); +} + +function appendAssistantLoading() { + const container = document.getElementById("chat-messages-container"); + if (!container) return null; + + const loadingId = 'loading-' + Date.now(); + const html = ` +
+
+ +
+
+
+ + ИИ обрабатывает запрос... +
+
+
+ `; + container.insertAdjacentHTML("beforeend", html); + return loadingId; +} + +function appendAssistantMessage(text, buttons = [], actionPayload = null) { + const container = document.getElementById("chat-messages-container"); + if (!container) return; + + let payloadHtml = ''; + let isHtmlBody = false; + + if (actionPayload) { + if (actionPayload.type === 'SNAPSHOTS_CARD' && typeof renderSnapshotsCard === 'function') { + payloadHtml = renderSnapshotsCard(actionPayload.data); + } else if (actionPayload.type === 'TASK_INTERACTIVE_CARD' && typeof renderInteractiveTaskCard === 'function') { + payloadHtml = renderInteractiveTaskCard(actionPayload.tasks); + } else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') { + const dlUrl = actionPayload.download_url || '#'; + const fName = actionPayload.filename || 'ROADMAP.md'; + const count = actionPayload.tasks_count || ''; + payloadHtml = ` +
+
+
+ +
+
+
${escapeHtml(fName)}
+
Задач выгружено: ${count} шт. · Markdown
+
+
+ + + Скачать файл + +
+ `; + } else if (actionPayload.type === 'SNAPSHOT_INSPECT_CARD') { + const records = actionPayload.records || []; + const inspectTableId = 'inspect-table-' + Date.now(); + const searchInputId = 'inspect-search-' + Date.now(); + + const rowsHtml = records.map((r, idx) => ` + + ${idx + 1} + ${escapeHtml(r.fio)} + ${escapeHtml(r.department)} + ${escapeHtml(r.time_in)} + ${escapeHtml(r.first_activity)} + ${escapeHtml(r.time_out)} + ${escapeHtml(r.in_building)} + ${r.is_present ? '✓ Да' : 'Нет'} + + `).join(''); + + payloadHtml = ` +
+
+
+ Срез #${escapeHtml(actionPayload.snapshot_id)} + · Всего: ${records.length} чел. (Присутствуют: ${actionPayload.present_count || 0}) +
+
+ + +
+
+
+ + + + + + + + + + + + + + ${rowsHtml} +
№СотрудникОтделВходАктивностьВыходВ зданииСтатус
+
+
+ `; + } else if (actionPayload.type === 'PROMPT_EDITOR') { + const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || ''; + const editorId = 'prompt-editor-' + Date.now(); + payloadHtml = ` +
+
+ Инлайн-редактор системного промпта: + Прямое редактирование текста +
+ +
+ + +
+
+ `; + } else if (actionPayload.type === 'RULES_EDITOR') { + const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || ''; + const editorId = 'rules-editor-' + Date.now(); + payloadHtml = ` +
+
+ Редактор базы знаний и правил компании: + Прямое изменение правил кадрового арбитража +
+ +
+ + +
+
+ `; + } else if (actionPayload.type === 'PROMPT_PREVIEW') { + isHtmlBody = true; + } + } + + let buttonsHtml = ''; + if (!actionPayload || (actionPayload.type !== 'PROMPT_EDITOR' && actionPayload.type !== 'RULES_EDITOR')) { + const allButtons = (buttons && buttons.length > 0) ? buttons : (actionPayload && actionPayload.buttons ? actionPayload.buttons : []); + if (allButtons && Array.isArray(allButtons) && allButtons.length > 0) { + buttonsHtml = ` +
+ ${allButtons.map(b => ` + + `).join('')} +
+ `; + } + } + + const bodyContent = isHtmlBody ? text : escapeHtml(text); + + const html = ` +
+
+ +
+
+
ИИ-ассистент SCUD Orion AI
+
${bodyContent}
+ ${payloadHtml} + ${buttonsHtml} +
+
+ `; + container.insertAdjacentHTML("beforeend", html); +} + +window.filterInspectTable = function(tableId, query) { + const table = document.getElementById(tableId); + if (!table) return; + const q = (query || '').trim().toLowerCase(); + const rows = table.querySelectorAll('.inspect-row'); + rows.forEach(r => { + const fio = r.getAttribute('data-fio') || ''; + const dept = r.getAttribute('data-dept') || ''; + if (!q || fio.includes(q) || dept.includes(q)) { + r.classList.remove('hidden'); + } else { + r.classList.add('hidden'); + } + }); +}; + +window.sendChatAction = function(actionText) { + if (!actionText || !actionText.trim()) return; + const input = document.getElementById("user-input"); + if (input) { + input.value = actionText.trim(); + updateInputHeightAndFade(input); + } + window.sendMessage(); +}; + +window.submitPromptDraftToDiff = function(editorId) { + const textarea = document.getElementById(editorId); + if (!textarea) return; + const newText = textarea.value.trim(); + if (!newText) { + alert("Текст системного промпта не может быть пустым"); + return; + } + + const command = `action:save_draft_prompt:::${newText}`; + const input = document.getElementById("user-input"); + if (input) input.value = command; + window.sendMessage(); +}; + +window.submitRulesDraftToDiff = function(editorId) { + const textarea = document.getElementById(editorId); + if (!textarea) return; + const newText = textarea.value.trim(); + if (!newText) { + alert("Правила не могут быть пустыми"); + return; + } + + const command = `action:save_draft_rules:::${newText}`; + const input = document.getElementById("user-input"); + if (input) input.value = command; + window.sendMessage(); +}; + +window.sendMessage = async function() { + const input = document.getElementById("user-input"); + if (!input) return; + + const messageText = input.value.trim(); + const fileToSend = currentAttachedFile; + + if (!messageText && !fileToSend) return; + + saveCommandToHistory(messageText); + + input.value = ""; + input.style.height = '24px'; + input.style.overflowY = 'hidden'; + input.style.maskImage = 'none'; + input.style.webkitMaskImage = 'none'; + window.clearAttachedFile(); + + // 1. Отрисовка сообщения пользователя в ленте + if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) { + appendUserMessage( + messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''), + fileToSend ? fileToSend.name : null + ); + } else { + appendUserMessage("Сформировать предпросмотр изменений"); + } + + // 2. Вставка лоадера + const loadingId = appendAssistantLoading(); + + // 3. Вызов точного скролла + scrollToUserMessageTop(); try { - const response = await fetch("/api/v1/chat", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}` - }, - body: JSON.stringify({ - message: message, - session_id: sessionId, - user_id: userId - }) + let res; + if (fileToSend) { + const formData = new FormData(); + formData.append("file", fileToSend); + formData.append("message", messageText); + formData.append("session_id", "web_session_main"); + + res = await fetch("/api/v1/chat/upload", { + method: "POST", + headers: { + "Authorization": AuthManager.getAuthHeaders()["Authorization"] + }, + body: formData + }); + + if (res.status === 404) { + res = await fetch("/api/v1/chat", { + method: "POST", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ + message: messageText || `Загружен файл: ${fileToSend.name}`, + session_id: "web_session_main" + }) + }); + } + } else { + res = await fetch("/api/v1/chat", { + method: "POST", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ + message: messageText, + session_id: "web_session_main" + }) + }); + } + + const loaderEl = document.getElementById(loadingId); + if (loaderEl) loaderEl.remove(); + + if (res.ok) { + const data = await res.json(); + const replyText = data.response || data.text || data.message || "Запрос выполнен."; + const buttons = data.buttons || []; + const actionPayload = data.action_payload || null; + + appendAssistantMessage(replyText, buttons, actionPayload); + + if (window.SidebarManager) { + SidebarManager.renderContent(); + } + } else { + const err = await res.json().catch(() => ({})); + appendAssistantMessage(`⚠️ Ошибка сервера (${res.status}): ${err.detail || "Не удалось получить ответ"}`); + } + } catch (err) { + console.error("Ошибка отправки сообщения:", err); + const loaderEl = document.getElementById(loadingId); + if (loaderEl) loaderEl.remove(); + appendAssistantMessage("⚠️ Ошибка соединения с сервером при отправке сообщения."); + } +}; + +window.clearAttachedFile = function() { + currentAttachedFile = null; + const preview = document.getElementById("file-attachment-preview"); + const nameEl = document.getElementById("file-attachment-name"); + const fileInput = document.getElementById("file-upload-input"); + + if (preview) preview.classList.add("hidden"); + if (nameEl) nameEl.innerText = ""; + if (fileInput) fileInput.value = ""; +}; + +function handleFileSelected(file) { + if (!file) return; + currentAttachedFile = file; + const preview = document.getElementById("file-attachment-preview"); + const nameEl = document.getElementById("file-attachment-name"); + + if (preview && nameEl) { + nameEl.innerText = file.name; + preview.classList.remove("hidden"); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const input = document.getElementById("user-input"); + const fileInput = document.getElementById("file-upload-input"); + const overlay = document.getElementById("global-drag-overlay"); + + if (input) { + input.style.lineHeight = '24px'; + input.style.height = '24px'; + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + if (e.shiftKey) return; + e.preventDefault(); + window.sendMessage(); + return; + } + + if (e.key === "ArrowUp") { + const isSingleLine = !input.value.includes("\n"); + const isAtBeginning = input.selectionStart === 0 && input.selectionEnd === 0; + + if (isSingleLine || isAtBeginning) { + if (chatInputHistory.length > 0) { + e.preventDefault(); + if (chatHistoryIndex === -1) { + temporaryCurrentInput = input.value; + chatHistoryIndex = chatInputHistory.length - 1; + } else if (chatHistoryIndex > 0) { + chatHistoryIndex--; + } + input.value = chatInputHistory[chatHistoryIndex]; + updateInputHeightAndFade(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + } else if (e.key === "ArrowDown") { + const isSingleLine = !input.value.includes("\n"); + const isAtEnd = input.selectionStart === input.value.length; + + if (isSingleLine || isAtEnd) { + if (chatHistoryIndex !== -1) { + e.preventDefault(); + if (chatHistoryIndex < chatInputHistory.length - 1) { + chatHistoryIndex++; + input.value = chatInputHistory[chatHistoryIndex]; + } else { + chatHistoryIndex = -1; + input.value = temporaryCurrentInput; + } + updateInputHeightAndFade(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + } }); - if (response.status === 403) { - console.warn("[Chat] Получен 403 Forbidden. Сбрасываем сессию и повторяем..."); - localStorage.removeItem("auth_token"); - if (typeof appendMessageToUI === "function") { - appendMessageToUI("assistant", "⚠️ Сессия была обновлена. Пожалуйста, отправьте сообщение повторно."); - } - return; - } - - if (!response.ok) { - throw new Error(`Ошибка сервера (HTTP ${response.status})`); - } - - const data = await response.json(); - const reply = data.response || data.message || "Ответ получен без текста."; - - if (typeof appendMessageToUI === "function") { - appendMessageToUI("assistant", reply); - } - - } catch (err) { - console.error("[Chat Error]:", err); - if (typeof appendMessageToUI === "function") { - appendMessageToUI("assistant", `⚠️ Не удалось связаться с сервером: ${err.message}`); - } + input.addEventListener("input", function() { + updateInputHeightAndFade(this); + chatHistoryIndex = -1; + }); } -} \ No newline at end of file + + if (fileInput) { + fileInput.addEventListener("change", (e) => { + if (e.target.files && e.target.files[0]) { + handleFileSelected(e.target.files[0]); + } + }); + } + + let dragCounter = 0; + + window.addEventListener('dragenter', (e) => { + e.preventDefault(); + dragCounter++; + if (overlay) { + overlay.classList.remove('hidden'); + } + }, false); + + window.addEventListener('dragleave', (e) => { + e.preventDefault(); + dragCounter--; + if (dragCounter <= 0) { + dragCounter = 0; + if (overlay) { + overlay.classList.add('hidden'); + } + } + }, false); + + window.addEventListener('dragover', (e) => { + e.preventDefault(); + }, false); + + window.addEventListener('drop', (e) => { + e.preventDefault(); + dragCounter = 0; + if (overlay) { + overlay.classList.add('hidden'); + } + + const dt = e.dataTransfer; + if (dt && dt.files && dt.files[0]) { + handleFileSelected(dt.files[0]); + } + }, false); +}); \ No newline at end of file diff --git a/modules/web_api/static/js/sidebar.js b/modules/web_api/static/js/sidebar.js new file mode 100644 index 0000000..4a67b0c --- /dev/null +++ b/modules/web_api/static/js/sidebar.js @@ -0,0 +1,472 @@ +/** + * =============================================================================== + * FILE: modules/web_api/static/js/sidebar.js + * ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами, + * подробным описанием управления контекстом и интеграцией чата. + * =============================================================================== + */ + +// Глобальная функция безопасного экранирования HTML +window.escapeHtml = function(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +window.SidebarManager = { + currentHub: 'TASKS', + currentSubTab: { + 'REGISTRIES': 'REMOTE' + }, + + hubs: [ + { id: 'TASKS', label: 'Задачи', icon: 'fa-list-check' }, + { id: 'SNAPSHOTS', label: 'Срезы', icon: 'fa-camera' }, + { id: 'REGISTRIES', label: 'Реестры', icon: 'fa-address-book' }, + { id: 'PROMPT', label: 'Промпт', icon: 'fa-terminal' }, + { id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' } + ], + + subTabs: { + 'REGISTRIES': [ + { id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' }, + { id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' } + ] + }, + + init() { + this.renderHeader(); + this.renderContent(); + }, + + setHub(hubId) { + this.currentHub = hubId; + this.renderHeader(); + this.renderContent(); + }, + + setSubTab(subTabId) { + this.currentSubTab[this.currentHub] = subTabId; + this.renderHeader(); + this.renderContent(); + }, + + renderHeader() { + const headerContainer = document.getElementById("sidebar-dynamic-header"); + if (!headerContainer) return; + + // 1. Основные 5 Хабов + const hubsHtml = ` +
+ ${this.hubs.map(h => { + const isActive = this.currentHub === h.id; + return ` + + `; + }).join('')} +
+ `; + + // 2. Подвкладки (только для хабов, где они требуются, например Реестры) + let subTabsHtml = ''; + if (this.subTabs[this.currentHub]) { + const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id; + subTabsHtml = ` +
+ ${this.subTabs[this.currentHub].map(st => { + const isSubActive = currentActiveSub === st.id; + return ` + + `; + }).join('')} +
+ `; + } + + headerContainer.innerHTML = hubsHtml + subTabsHtml; + }, + + renderContent() { + const contentContainer = document.getElementById("sidebar-dynamic-content"); + if (!contentContainer) return; + + contentContainer.scrollTop = 0; + + switch (this.currentHub) { + case 'TASKS': + this.renderTasksView(contentContainer); + break; + case 'SNAPSHOTS': + this.renderSnapshotsView(contentContainer); + break; + case 'REGISTRIES': + this.renderRegistriesView(contentContainer); + break; + case 'PROMPT': + this.renderPromptView(contentContainer); + break; + case 'CONTEXT': + this.renderContextView(contentContainer); + break; + default: + contentContainer.innerHTML = `
Раздел в разработке
`; + } + }, + + // ========================================================================= + // ХАБ 1: ЗАДАЧИ + // ========================================================================= + renderTasksView(container) { + container.innerHTML = ` +
+
+ Загрузка задач... +
+
+ `; + if (window.loadTasks) { + window.loadTasks(); + } + }, + + // ========================================================================= + // ХАБ 2: СРЕЗЫ СКУД + // ========================================================================= + async renderSnapshotsView(container) { + container.innerHTML = `
Загрузка срезов...
`; + try { + const res = await fetch("/api/v1/snapshots", { headers: AuthManager.getAuthHeaders() }); + const data = res.ok ? await res.json() : { snapshots: [] }; + const snaps = data.snapshots || []; + + if (snaps.length === 0) { + container.innerHTML = `
Срезы СКУД не найдены
`; + return; + } + + const itemsHtml = snaps.map(s => ` +
+
+
+ ${escapeHtml(s.snapshot_id)} + ${s.is_final ? 'Финал Y' : ''} +
+
${escapeHtml(s.snapshot_time)} · ${s.record_count || 0} зап.
+
+ +
+ `).join(''); + + container.innerHTML = ` +
+
+ Всего срезов: ${snaps.length} + +
+
${itemsHtml}
+
+ `; + } catch (e) { + container.innerHTML = `
Ошибка загрузки срезов
`; + } + }, + + // ========================================================================= + // ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ) + // ========================================================================= + renderRegistriesView(container) { + const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE'; + if (subTab === 'REMOTE') { + this.renderRemoteWorkersView(container); + } else { + this.renderExceptionsView(container); + } + }, + + async renderRemoteWorkersView(container) { + container.innerHTML = `
Загрузка удаленщиков...
`; + try { + const res = await fetch("/api/v1/remote-workers", { headers: AuthManager.getAuthHeaders() }); + const data = res.ok ? await res.json() : { workers: [] }; + const workers = data.workers || []; + + const listHtml = workers.map(w => { + const dFrom = w.date_from ? w.date_from : 'сегодня'; + const dTo = w.date_to ? w.date_to : 'бессрочно'; + const periodLabel = (!w.date_to) ? `с ${dFrom} (бессрочно)` : `${dFrom} — ${dTo}`; + + return ` +
+
+
${escapeHtml(w.fio)}
+
${escapeHtml(w.department || 'Все')}
+
+ + ${escapeHtml(periodLabel)} +
+
+
+ + +
+
+ `; + }).join(''); + + container.innerHTML = ` +
+
+ В реестре: ${workers.length} чел. + +
+
+ ${workers.length > 0 ? listHtml : '
Список удаленщиков пуст
'} +
+
+ `; + } catch (e) { + container.innerHTML = `
Ошибка загрузки реестра удаленщиков
`; + } + }, + + async deleteRemoteWorker(fio) { + if (!confirm(`Удалить сотрудника ${fio} из реестра удаленщиков?`)) return; + try { + const res = await fetch(`/api/v1/remote-workers?fio=${encodeURIComponent(fio)}`, { + method: "DELETE", + headers: AuthManager.getAuthHeaders() + }); + if (res.ok) { + this.renderContent(); + } else { + alert("Ошибка удаления"); + } + } catch (e) { + alert("Ошибка сети"); + } + }, + + async renderExceptionsView(container) { + container.innerHTML = `
Загрузка исключений...
`; + try { + const res = await fetch("/api/v1/exceptions/", { headers: AuthManager.getAuthHeaders() }); + const data = res.ok ? await res.json() : {}; + const categories = [ + { key: 'include_fio', title: 'Белый список (ФИО)' }, + { key: 'fio', title: 'Исключенные сотрудники (ФИО)' }, + { key: 'departments', title: 'Исключенные отделы' }, + { key: 'positions', title: 'Исключенные должности' } + ]; + + const html = categories.map(cat => { + const items = data[cat.key] || []; + return ` +
+
+ ${cat.title} (${items.length}) + +
+
+ ${items.map(it => ` + + ${escapeHtml(it)} + + + `).join('') || 'Пусто'} +
+
+ `; + }).join(''); + + container.innerHTML = `
${html}
`; + } catch (e) { + container.innerHTML = `
Ошибка загрузки исключений
`; + } + }, + + async addExceptionPrompt(category) { + const val = prompt(`Введите значение для категории [${category}]:`); + if (!val || !val.trim()) return; + try { + const res = await fetch("/api/v1/exceptions/", { + method: "POST", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ category: category, value: val.trim() }) + }); + if (res.ok) this.renderContent(); + else alert("Ошибка добавления"); + } catch (e) { + alert("Ошибка сети"); + } + }, + + async deleteExceptionItem(category, value) { + if (!confirm(`Удалить "${value}" из ${category}?`)) return; + try { + const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, { + method: "DELETE", + headers: AuthManager.getAuthHeaders() + }); + if (res.ok) this.renderContent(); + else alert("Ошибка удаления"); + } catch (e) { + alert("Ошибка сети"); + } + }, + + // ========================================================================= + // ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ + // ========================================================================= + renderPromptView(container) { + container.innerHTML = ` +
+
+ + Инструкции и регламенты ИИ + + Управление системными директивами, базой знаний и правилами арбитража кадровых аномалий СКУД и 1С. +
+ +
+ + + +
+
+ `; + }, + + // ========================================================================= + // ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ (С ПОДРОБНЫМ ОПИСАНИЕМ) + // ========================================================================= + renderContextView(container) { + container.innerHTML = ` +
+
+ + Управление памятью чата + + +
+
+ +
+ Мягкая очистка: + удаляет только служебные транзакции (карточки срезов, временные превью промпта, промежуточные подтверждения). Смысловой диалог пользователя сохраняется. +
+
+ +
+ +
+ Полный сброс: + полностью стирает контекст активной сессии из базы данных и очищает окно чата. Используется при переходе к новой дате или новой теме анализа. +
+
+
+
+ +
+ + + +
+
+ `; + }, + + async handleFullSessionReset() { + if (!confirm("Вы действительно хотите полностью очистить историю диалога и сбросить сессию чата?")) { + return; + } + + // Очищаем локальное окно чата до стартового приветствия + const chatContainer = document.getElementById("chat-messages-container"); + if (chatContainer) { + chatContainer.innerHTML = ` +
+
+ +
+
+
ИИ-ассистент SCUD Orion AI
+
+ Сессия чата очищена. Память ассистента сброшена. Задайте новый вопрос или команду. +
+
+
+ `; + } + + try { + await fetch("/api/v1/chat", { + method: "POST", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ + message: "сбрось сессию полностью", + session_id: "web_session_main" + }) + }); + } catch (e) { + console.error("Ошибка запроса сброса сессии:", e); + } + } +}; + +document.addEventListener("DOMContentLoaded", () => { + if (window.AuthManager && AuthManager.isAuthenticated()) { + SidebarManager.init(); + } +}); \ No newline at end of file diff --git a/modules/web_api/static/js/tasks.js b/modules/web_api/static/js/tasks.js index a375a93..02da77f 100644 --- a/modules/web_api/static/js/tasks.js +++ b/modules/web_api/static/js/tasks.js @@ -1,117 +1,200 @@ /** -=============================================================================== -FILE: modules/web_api/static/js/tasks.js -PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: web_api / static / js -ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer). -=============================================================================== -*/ + * =============================================================================== + * FILE: modules/web_api/static/js/tasks.js + * ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер). + * =============================================================================== + */ -let currentTaskFilter = 'IN_PROGRESS'; +let currentTasksFilter = 'ALL'; +let tasksCache = []; + +function getTasksContainer() { + return document.getElementById("tasks-list-container") || + document.getElementById("sidebar-dynamic-content") || + document.getElementById("tasks-list"); +} async function loadTasks() { - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - if (!token) return; + const container = getTasksContainer(); + if (!container) return; + + container.innerHTML = ` +
+ Загрузка задач... +
+ `; try { const res = await fetch("/api/v1/tasks", { - headers: { "Authorization": "Bearer " + token } + headers: AuthManager.getAuthHeaders() }); - if (res.ok) { - const data = await res.json(); - renderSidebarTasks(data.tasks || []); - } else { - renderSidebarError("Ошибка доступа. Авторизуйтесь снова."); + + if (!res.ok) { + if (res.status === 401) { + showAuthModal(); + return; + } + throw new Error(`Ошибка сервера (${res.status})`); } - } catch (e) { - console.error("Ошибка загрузки задач:", e); - renderSidebarError("Ошибка сети. Сервер недоступен."); + + const data = await res.json(); + tasksCache = Array.isArray(data) ? data : (data.tasks || []); + renderTasksUI(); + } catch (err) { + console.error("[Tasks] Ошибка загрузки:", err); + container.innerHTML = ` +
+ + Не удалось загрузить задачи + +
+ `; } } -function renderSidebarError(msg) { - // Поддерживаем оба варианта ID (новый и старый) для обратной совместимости - const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container"); - if (container) { - container.innerHTML = `
${msg}
`; - } +function setTaskFilter(filter) { + currentTasksFilter = filter; + renderTasksUI(); } -function filterTasksByTab(status) { - currentTaskFilter = status; - - // Сброс стилей всех кнопок-вкладок - document.querySelectorAll('.task-tab-btn').forEach(btn => { - btn.classList.remove('text-indigo-600', 'bg-indigo-50'); - btn.classList.add('text-slate-600'); - }); - - // Установка активного стиля для выбранной вкладки - const activeBtnId = { - 'IN_PROGRESS': 'tab-in-progress', - 'BACKLOG': 'tab-backlog', - 'COMPLETED': 'tab-completed', - 'ALL': 'tab-all' - }[status]; - - if (activeBtnId) { - const btn = document.getElementById(activeBtnId); - if (btn) { - btn.classList.remove('text-slate-600', 'hover:bg-slate-100'); - btn.classList.add('text-indigo-600', 'bg-indigo-50'); - } - } - - loadTasks(); -} - -function renderSidebarTasks(tasks) { - const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container"); +function renderTasksUI() { + const container = getTasksContainer(); if (!container) return; - - let filtered = tasks; - if (currentTaskFilter !== 'ALL') { - filtered = tasks.filter(t => t.status === currentTaskFilter); + + let filtered = tasksCache; + if (currentTasksFilter === 'IN_PROGRESS') { + filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS'); + } else if (currentTasksFilter === 'BACKLOG') { + filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED'); + } else if (currentTasksFilter === 'COMPLETED') { + filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE'); } + const filtersHtml = ` +
+ + + + +
+ `; + + const addBtnHtml = ` +
+ Задачи: ${filtered.length} + +
+ `; + if (filtered.length === 0) { - container.innerHTML = `
Нет задач в этой категории
`; + container.innerHTML = ` + ${filtersHtml} + ${addBtnHtml} +
+ Нет задач в выбранной категории +
+ `; return; } - container.innerHTML = filtered.map(t => { - const isCompleted = t.status === 'COMPLETED'; - const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL' - ? 'text-rose-600 bg-rose-50 border-rose-200' - : t.priority === 'MEDIUM' - ? 'text-amber-600 bg-amber-50 border-amber-200' - : 'text-slate-600 bg-slate-50 border-slate-200'; - + const itemsHtml = filtered.map(t => { + const isDone = t.status === 'COMPLETED' || t.status === 'DONE'; + const priorityColors = { + 'HIGH': 'bg-rose-50 text-rose-700 border-rose-200', + 'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200', + 'LOW': 'bg-slate-50 text-slate-600 border-slate-200' + }; + const pClass = priorityColors[t.priority] || priorityColors['MEDIUM']; + return ` -
- +
- #${t.id} - ${isCompleted - ? ` Готово` - : `${t.priority || 'LOW'}` - } +
+ + ${escapeHtml(t.title)} +
+ ${t.priority || 'NORMAL'}
-
${escapeHtml(t.title || 'Без названия')}
- -
- ${escapeHtml(t.module || 'general')} - ${t.due_date ? `${escapeHtml(t.due_date)}` : ''} +
+ ${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')} +
+ +
`; }).join(''); + + container.innerHTML = ` + ${filtersHtml} + ${addBtnHtml} +
+ ${itemsHtml} +
+ `; } -// Глобальная инициализация при загрузке DOM -document.addEventListener("DOMContentLoaded", () => { - // Небольшая задержка, чтобы гарантировать применение токена - setTimeout(loadTasks, 200); -}); \ No newline at end of file +async function toggleTaskStatus(id, currentStatus) { + const newStatus = (currentStatus === 'COMPLETED' || currentStatus === 'DONE') ? 'IN_PROGRESS' : 'COMPLETED'; + try { + const res = await fetch(`/api/v1/tasks/${id}`, { + method: "PATCH", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ status: newStatus }) + }); + if (res.ok) { + loadTasks(); + } + } catch (e) { + console.error("Ошибка смены статуса задачи:", e); + } +} + +async function deleteTaskItem(id) { + if (!confirm("Удалить эту задачу?")) return; + try { + const res = await fetch(`/api/v1/tasks/${id}`, { + method: "DELETE", + headers: AuthManager.getAuthHeaders() + }); + if (res.ok) { + loadTasks(); + } + } catch (e) { + alert("Ошибка сети при удалении"); + } +} + +async function openCreateTaskModal() { + const title = prompt("Введите описание новой задачи:"); + if (!title || !title.trim()) return; + + try { + const res = await fetch("/api/v1/tasks", { + method: "POST", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ + title: title.trim(), + priority: "MEDIUM", + status: "IN_PROGRESS" + }) + }); + if (res.ok) { + loadTasks(); + } else { + alert("Не удалось создать задачу"); + } + } catch (e) { + alert("Ошибка сети"); + } +} + +window.loadTasks = loadTasks; +window.setTaskFilter = setTaskFilter; \ No newline at end of file diff --git a/services/data_loader.py b/services/data_loader.py index 2e2e99a..8a90002 100644 --- a/services/data_loader.py +++ b/services/data_loader.py @@ -7,6 +7,7 @@ ROLE: Надежная загрузка штата и отсутствий (MS S import os import warnings +from datetime import datetime # <-- ДОБАВИТЬ ЭТУ СТРОКУ import pandas as pd from config import ( normalize_fio, clean_scud_fio_light, load_exceptions, @@ -101,27 +102,84 @@ def load_absent_data(date_str): if df_absent is None: df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) - # Обогащение удаленщиками из static_reason_workers.csv + # Обогащение удаленщиками из static_reason_workers.csv с ротацией просроченных записей try: static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") if os.path.exists(static_path): - df_static = pd.read_csv(static_path, encoding='utf-8') - if 'fio' in df_static.columns and 'reason' in df_static.columns: - df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) - existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() - - new_rows = [] - for _, s_row in df_static.iterrows(): - if s_row['fio_clean'] not in existing_fios: - new_rows.append({ - 'fio_clean': s_row['fio_clean'], - 'Вид_отсутствия': s_row['reason'] + df_static = pd.read_csv(static_path, dtype=str, on_bad_lines='skip').fillna("") + + for col in ['fio', 'reason', 'department', 'date_from', 'date_to']: + if col not in df_static.columns: + df_static[col] = "" + + today_date = datetime.now().date() + + # Разбор целевой даты отчета + clean_target_str = str(date_str).replace('_', '.') + try: + clean_target_date = datetime.strptime(clean_target_str, "%d.%m.%Y").date() + except ValueError: + clean_target_date = today_date + + def parse_date_safe(d_val): + if not d_val or str(d_val).lower() in ['nan', 'none', '', 'nat']: + return None + s = str(d_val).strip().replace('_', '.') + for fmt in ("%d.%m.%Y", "%Y-%m-%d"): + try: + return datetime.strptime(s, fmt).date() + except ValueError: + pass + return None + + active_for_file = [] + new_rows_for_report = [] + file_changed = False + + existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() + + for _, s_row in df_static.iterrows(): + fio_raw = str(s_row.get('fio', '')).strip() + if not fio_raw: + continue + + fio_c = normalize_fio(fio_raw) + reason = str(s_row.get('reason', '')).strip() or "Дистанционная работа" + + d_from = parse_date_safe(s_row.get('date_from')) + d_to = parse_date_safe(s_row.get('date_to')) + + # 1. Физическая ротация просроченных: только если текущий реальный день (today) строго больше date_to + if d_to is not None and today_date > d_to: + print(f" [🧹] Удаленка истекла: {fio_raw} (до {d_to.strftime('%d.%m.%Y')}). Удалена из CSV.") + file_changed = True + continue + + active_for_file.append(s_row.to_dict()) + + # 2. Проверка действия удаленки на дату формируемого отчета: + # Если date_from не указана — действует всегда до date_to + is_after_start = (d_from is None) or (clean_target_date >= d_from) + is_before_end = (d_to is None) or (clean_target_date <= d_to) + + if is_after_start and is_before_end: + if fio_c not in existing_fios: + new_rows_for_report.append({ + 'fio_clean': fio_c, + 'Вид_отсутствия': reason }) - if new_rows: - df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True) - print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows)} чел. из static_reason_workers.csv") + existing_fios.add(fio_c) + + # Перезаписываем CSV только если реально были удалены просроченные сотрудники + if file_changed: + pd.DataFrame(active_for_file).to_csv(static_path, index=False, encoding='utf-8') + print(" [✓] Файл static_reason_workers.csv синхронизирован без просроченных записей.") + + if new_rows_for_report: + df_absent = pd.concat([df_absent, pd.DataFrame(new_rows_for_report)], ignore_index=True) + print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows_for_report)} чел. в отчет за {date_str}") except Exception as e: - print(f" [⚠️] Ошибка чтения static_reason_workers.csv: {e}") + print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}") return df_absent if not df_absent.empty else None diff --git a/services/excel_exporter.py b/services/excel_exporter.py index 794ba05..0d953d1 100644 --- a/services/excel_exporter.py +++ b/services/excel_exporter.py @@ -1,12 +1,18 @@ -import math +""" +=============================================================================== +FILE: services/excel_exporter.py +ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter. + Корректный расчет часов удаленщиков и исключение лишних списков. +=============================================================================== +""" + import os +import math import time -import openpyxl import pandas as pd +import xlsxwriter from datetime import datetime, timedelta -from openpyxl import Workbook -from openpyxl.styles import Font, Alignment, PatternFill, Border, Side -from openpyxl.utils import get_column_letter +from xlsxwriter.exceptions import FileCreateError from config import REPORTS_DIR MONTHS_RU_GENITIVE = { @@ -47,29 +53,25 @@ def get_dated_reports_dir(date_str): return target_dir -THIN_SIDE = Side(border_style="thin", color="D3D3D3") -THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE) - -FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") -FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") -FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") -FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") -FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", 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_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") - -CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"] -YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") -LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") -GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") +def safe_close_workbook(wb, output_path, target_dir, filename): + try: + wb.close() + print(f"[✓] Успешно сохранен: {output_path}") + return output_path + except (FileCreateError, OSError, PermissionError): + alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") + alt_path = os.path.join(target_dir, alt_filename) + try: + wb.filename = alt_path + wb._store_workbook() + print(f"[⚠️] Исходный файл открыт в Excel! Сохранено как: {alt_path}") + return alt_path + except Exception as e: + print(f"[❌] Ошибка сохранения даже резервного файла: {e}") + return output_path 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]) @@ -84,10 +86,25 @@ def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]: def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): - if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "": + """ + Расчет отклонения от нормы. + Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение. + """ + reason_clean = str(reason).strip().lower() if pd.notna(reason) else "" + is_remote = "удален" in reason_clean or "дистанцион" in reason_clean + + has_building_time = isinstance(time_in_building_str, str) and time_in_building_str not in ['00:00', '0', '', 'None', 'nan', 'NaN'] + + # Если есть уважительная причина (больничный, отпуск, командировка и т.д.) не удаленка + if reason_clean != "" and not is_remote: return "0:00" - if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']: + # Если удаленщик работал исключительно из дома (00:00 в здании) + if is_remote and not has_building_time: + return "0:00" + + # Если сотрудника не было в здании и нет уважительной причины + if not has_building_time: return f"-{norm_hours}:00" try: @@ -97,7 +114,7 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min total_in_building_minutes = hh * 60 + mm if total_in_building_minutes == 0: - return f"-{norm_hours}:00" + return "0:00" if is_remote else f"-{norm_hours}:00" work_minutes = max(0, total_in_building_minutes - lunch_minutes) norm_minutes = norm_hours * 60 @@ -113,29 +130,12 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min return f"{sign}{res_hh}:{res_mm:02d}" except Exception: - return f"-{norm_hours}:00" + return "0:00" if is_remote else f"-{norm_hours}:00" -def apply_borders_to_cell(cell, border=THIN_BORDER): - cell.border = border - - -def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False): - cell_a = ws.cell(row=r_num, column=1) - cell_b = ws.cell(row=r_num, column=2) - if fill_obj: - cell_a.fill = fill_obj - cell_b.fill = fill_obj - apply_borders_to_cell(cell_a) - apply_borders_to_cell(cell_b) - if is_bold and bold_font: - cell_a.font = bold_font - cell_b.font = bold_font - if align_b: - cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b) - - -# --- 1. СВОДКА НА СЕГОДНЯ --- +# ============================================================================= +# 1. ЕЖЕДНЕВНАЯ СВОДКА НА СЕГОДНЯ +# ============================================================================= def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): date_clean = str(date_str).replace('_', '.') if not filename: @@ -143,65 +143,92 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): target_dir = get_dated_reports_dir(date_clean) output_path = os.path.join(target_dir, filename) - wb = Workbook() - ws = wb.active - ws.title = "Лист_1" - ws.sheet_properties.outlinePr.summaryBelow = False - ws.sheet_properties.outlinePr.summaryRight = False - ws.sheet_properties.outlinePr.showOutlineSymbols = True - ws.sheet_view.showOutlineSymbols = True + wb = xlsxwriter.Workbook(output_path) + ws = wb.add_worksheet("Лист_1") - bold_font = Font(name="Calibri", size=11, bold=True) + ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False) - ws.cell(row=1, column=1, value="Сводка на") - ws.cell(row=1, column=2, value=date_clean) - format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) + def make_fmt(bg_color=None, bold=False, align="left", wrap=False): + d = { + 'font_name': 'Calibri', + 'font_size': 11, + 'bold': bold, + 'align': align, + 'valign': 'vcenter', + 'border': 1, + 'border_color': '#D3D3D3', + 'text_wrap': wrap + } + if bg_color: + d['bg_color'] = bg_color + return wb.add_format(d) - apply_borders_to_cell(ws.cell(row=2, column=1)) - apply_borders_to_cell(ws.cell(row=2, column=2)) + fmt_hdr_l = make_fmt(bg_color='#D9E1F2', bold=True, align="left") + fmt_hdr_r = make_fmt(bg_color='#D9E1F2', bold=True, align="right") + fmt_tot_l = make_fmt(bg_color='#F2F2F2', bold=True, align="left") + fmt_tot_r = make_fmt(bg_color='#F2F2F2', bold=True, align="right") + fmt_empty = make_fmt() - ws.cell(row=3, column=1, value="По списку") - 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) + ws.set_row(0, 20) + ws.write(0, 0, "Сводка на", fmt_hdr_l) + ws.write(0, 1, date_clean, fmt_hdr_r) - current_row = 4 + ws.set_row(1, 20) + ws.write(1, 0, "", fmt_empty) + ws.write(1, 1, "", fmt_empty) + ws.set_row(2, 20) + ws.write(2, 0, "По списку", fmt_tot_l) + ws.write(2, 1, len(merged_df), fmt_tot_r) + + current_row = 3 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 + # 1. Неизвестно (Раскрыто по умолчанию) unexplained = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (~is_no_pass) & (~is_exc) ] - ws.cell(row=current_row, column=1, value="неизвестно") - ws.cell(row=current_row, column=2, value=len(unexplained)) - format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) + fmt_unexp_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left") + fmt_unexp_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right") + fmt_unexp_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left") + fmt_unexp_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "неизвестно", fmt_unexp_hl) + ws.write(current_row, 1, len(unexplained), fmt_unexp_hr) current_row += 1 for fio in sorted(unexplained['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = False + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) + ws.write(current_row, 0, fio, fmt_unexp_rl) + ws.write(current_row, 1, "", fmt_unexp_rr) current_row += 1 - no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() - ws.cell(row=current_row, column=1, value="Нет пропуска") - ws.cell(row=current_row, column=2, value=len(no_pass_df)) - format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) + # 2. Нет пропуска (Раскрыто по умолчанию) + no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() + fmt_np_hl = make_fmt(bg_color='#E1F5FE', bold=True, align="left") + fmt_np_hr = make_fmt(bg_color='#E1F5FE', bold=True, align="right") + fmt_np_rl = make_fmt(bg_color='#E1F5FE', bold=False, align="left") + fmt_np_rr = make_fmt(bg_color='#E1F5FE', bold=False, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "Нет пропуска", fmt_np_hl) + ws.write(current_row, 1, len(no_pass_df), fmt_np_hr) current_row += 1 if not no_pass_df.empty: for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = False + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) + ws.write(current_row, 0, fio, fmt_np_rl) + ws.write(current_row, 1, "", fmt_np_rr) current_row += 1 + # 3. Официальные отсутствия (Сотрудники из исключений при наличии документа 1С попадают сюда) reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) @@ -209,55 +236,63 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & - (~is_remote_reason) & - (~is_exc) + (~is_remote_reason) ] absent_groups = absent_only.groupby('Вид_отсутствия') - - for idx_cat, (cat_name, group) in enumerate(absent_groups): - hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)] - cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid") + pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF'] - ws.cell(row=current_row, column=1, value=cat_name) - ws.cell(row=current_row, column=2, value=len(group)) - format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) + for idx_cat, (cat_name, group) in enumerate(absent_groups): + hex_c = pastels[idx_cat % len(pastels)] + fmt_cat_hl = make_fmt(bg_color=hex_c, bold=True, align="left") + fmt_cat_hr = make_fmt(bg_color=hex_c, bold=True, align="right") + fmt_cat_rl = make_fmt(bg_color=hex_c, bold=False, align="left") + fmt_cat_rr = make_fmt(bg_color=hex_c, bold=False, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, cat_name, fmt_cat_hl) + ws.write(current_row, 1, len(group), fmt_cat_hr) current_row += 1 for fio in sorted(group['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, cat_fill, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) + ws.write(current_row, 0, fio, fmt_cat_rl) + ws.write(current_row, 1, "", fmt_cat_rr) current_row += 1 - present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] - ws.cell(row=current_row, column=1, value="Итого на работе") - 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) + # 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок) + exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))] + present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] + + total_present_count = len(present_scud) + len(exc_without_doc) + + fmt_pres_hl = make_fmt(bg_color='#E2EFDA', bold=True, align="left") + fmt_pres_hr = make_fmt(bg_color='#E2EFDA', bold=True, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "Итого на работе", fmt_pres_hl) + ws.write(current_row, 1, total_present_count, fmt_pres_hr) current_row += 1 - if not present.empty: - for fio in sorted(present['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True - current_row += 1 - + # 5. Удаленная работа (Свернуто) 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=2, value=len(remote_home)) - format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font) + fmt_rem_hl = make_fmt(bg_color='#E8F8F5', bold=True, align="left") + fmt_rem_hr = make_fmt(bg_color='#E8F8F5', bold=True, align="right") + fmt_rem_rl = make_fmt(bg_color='#E8F8F5', bold=False, align="left") + fmt_rem_rr = make_fmt(bg_color='#E8F8F5', bold=False, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl) + ws.write(current_row, 1, len(remote_home), fmt_rem_hr) current_row += 1 if not remote_home.empty: for fio in sorted(remote_home['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) + ws.write(current_row, 0, fio, fmt_rem_rl) + ws.write(current_row, 1, "", fmt_rem_rr) current_row += 1 + # 6. Аномалии СКУД и 1С (Свернуто) anomalies = merged_df[ (~is_exc) & ( ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & @@ -267,10 +302,14 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') ) ] - - ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") - ws.cell(row=current_row, column=2, value=len(anomalies)) - format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) + fmt_anom_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left") + fmt_anom_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right") + fmt_anom_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left") + fmt_anom_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="left", wrap=True) + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl) + ws.write(current_row, 1, len(anomalies), fmt_anom_hr) current_row += 1 chars_per_line_b = 30 @@ -285,50 +324,24 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})" else: reason_text = f"В 1С: {reason}" - - cell_a = ws.cell(row=current_row, column=1, value=f"{fio}") - cell_b = ws.cell(row=current_row, column=2, value=reason_text) - cell_a.fill = FILL_ANOMALY - cell_b.fill = FILL_ANOMALY - apply_borders_to_cell(cell_a) - apply_borders_to_cell(cell_b) - cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) - cell_a.alignment = Alignment(horizontal="left", vertical="center") - + lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1 - ws.row_dimensions[current_row].height = max(lines_count * 18, 20) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True + row_h = max(lines_count * 18, 20) + + ws.set_row(current_row, row_h, None, {'level': 1, 'hidden': True, 'collapsed': True}) + ws.write(current_row, 0, fio, fmt_anom_rl) + ws.write(current_row, 1, reason_text, fmt_anom_rr) current_row += 1 - exceptions_df = merged_df[is_exc] - ws.cell(row=current_row, column=1, value="Исключения") - 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) - current_row += 1 - - if not exceptions_df.empty: - for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) - format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False) - ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True - current_row += 1 - - ws.column_dimensions['A'].width = 45.0 - ws.column_dimensions['B'].width = 38.0 - - try: - wb.save(output_path) - print(f"[✓] Ежедневная сводка сохранена: {output_path}") - except (PermissionError, OSError): - alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") - alt_path = os.path.join(target_dir, alt_filename) - wb.save(alt_path) - print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") + ws.set_column(0, 0, 45) + ws.set_column(1, 1, 38) + + safe_close_workbook(wb, output_path, target_dir, filename) -# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ПРАВИЛОМ 8.5ч) --- +# ============================================================================= +# 2. ДЕТАЛЬНЫЙ СУТОЧНЫЙ ОТЧЕТ ЗА ВЧЕРА +# ============================================================================= def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): date_clean = str(date_str).replace('_', '.') if not filename: @@ -341,29 +354,37 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): target_dir = get_dated_reports_dir(date_clean) output_path = os.path.join(target_dir, filename) - wb = Workbook() - ws = wb.active - ws.title = "Детальный_отчет" - ws["B2"] = "Дата:" - ws["D2"] = date_clean - ws["B2"].font = Font(name="Arial", size=10, bold=True) - ws["D2"].font = Font(name="Arial", size=10, bold=True) + wb = xlsxwriter.Workbook(output_path) + ws = wb.add_worksheet("Детальный_отчет") + + def make_fmt(bg_color=None, bold=False, align="left", wrap=False): + d = { + 'font_name': 'Arial', + 'font_size': 10, + 'bold': bold, + 'align': align, + 'valign': 'vcenter', + 'border': 1, + 'border_color': '#D3D3D3', + 'text_wrap': wrap + } + if bg_color: + d['bg_color'] = bg_color + return wb.add_format(d) + + fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True}) + ws.write(1, 1, "Дата:", fmt_date_lbl) + ws.write(1, 3, date_clean, fmt_date_lbl) headers = [ "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "находился в здании", "причина отсутствия", "норма", "отклонение от нормы" ] - ws.append([]) - ws.append(headers) - - header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") - for col_idx in range(1, len(headers) + 1): - cell = ws.cell(row=4, column=col_idx) - cell.fill = header_fill - cell.font = Font(name="Arial", size=10, bold=True) - cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) - apply_borders_to_cell(cell) + fmt_hdr = make_fmt(bg_color='#D9E1F2', bold=True, align="center", wrap=True) + ws.set_row(3, 26) + for col_idx, h_text in enumerate(headers): + ws.write(3, col_idx, h_text, fmt_hdr) start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня' end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня' @@ -372,6 +393,7 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): chars_per_line_h = 24 for idx, row in df_export.reset_index(drop=True).iterrows(): + row_num = 4 + idx is_present = row.get('Пришел', False) absence_reason = row.get('Вид_отсутствия', '') has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' @@ -382,7 +404,6 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): first_act_val = str(row.get('Первая_активность', '—')).strip() has_first_act = first_act_val not in ['—', '', 'None', 'nan'] - # ⭐️ ПРАВИЛО 8.5ч: если есть вход, но нет выхода (и нет официального документа отсутствия) if in_val not in ['Нет входа', '—', '', 'nan', 'None'] and out_val in ['Нет выхода', '—', '', 'nan', 'None'] and not has_reason: out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val) else: @@ -390,60 +411,104 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) - ws.append([ - idx + 1, - row.get('Сотрудник', ''), - dept_scud_val, - in_val, - first_act_val, - out_val, - in_building_str, - absence_reason if has_reason else '', - 8, - deviation_val - ]) - - row_num = 5 + idx + row_color = None if is_present and has_reason: - row_fill = GREEN_FILL + row_color = '#E2EFDA' elif not is_present and has_reason: - row_fill = YELLOW_FILL + row_color = '#FFF2CC' elif not is_present and not has_reason and not has_first_act: - row_fill = LIGHT_RED_FILL - else: - row_fill = None + row_color = '#FCE4D6' val_h_str = str(absence_reason) if has_reason else "" lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1 - ws.row_dimensions[row_num].height = max(lines_count * 18, 20) + ws.set_row(row_num, max(lines_count * 18, 20)) - for col_idx in range(1, len(headers) + 1): - cell = ws.cell(row=row_num, column=col_idx) - apply_borders_to_cell(cell) - if row_fill: - cell.fill = row_fill - if col_idx == 8: - cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) - elif col_idx in [1, 4, 5, 6, 7, 9, 10]: - cell.alignment = Alignment(horizontal="center", vertical="center") - else: - cell.alignment = Alignment(horizontal="left", vertical="center") + values = [ + (idx + 1, 'center', False), + (row.get('Сотрудник', ''), 'left', False), + (dept_scud_val, 'center', False), + (in_val, 'center', False), + (first_act_val, 'center', False), + (out_val, 'center', False), + (in_building_str, 'center', False), + (absence_reason if has_reason else '', 'left', True), + (8, 'center', False), + (deviation_val, 'center', False) + ] - for col in ws.columns: - col_letter = get_column_letter(col[0].column) - max_len = max((len(str(cell.value or '')) for cell in col), default=8) - ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 35) + for col_idx, (val, align_type, is_wrap) in enumerate(values): + fmt = make_fmt(bg_color=row_color, align=align_type, wrap=is_wrap) + ws.write(row_num, col_idx, val, fmt) - try: - wb.save(output_path) - print(f"[✓] Детальный отчет сохранен: {output_path}") - except (PermissionError, OSError): - alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") - alt_path = os.path.join(target_dir, alt_filename) - wb.save(alt_path) - print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") + col_widths = { + 0: 4, + 1: 33, + 2: 13, + 3: 11, + 4: 11, + 5: 11, + 6: 12, + 7: 24, + 8: 6, + 9: 11 + } + + for col_idx, width in col_widths.items(): + ws.set_column(col_idx, col_idx, width) + + safe_close_workbook(wb, output_path, target_dir, filename) +# ============================================================================= +# 3. СЫРОЙ СКУД +# ============================================================================= def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): output_path = os.path.join(REPORTS_DIR, filename) - df_scud.to_excel(output_path, index=False) \ No newline at end of file + target_dir = os.path.dirname(output_path) + wb = xlsxwriter.Workbook(output_path) + ws = wb.add_worksheet("Сырые_данные") + + fmt_hdr = wb.add_format({ + 'font_name': 'Calibri', + 'font_size': 11, + 'bold': True, + 'bg_color': '#D9E1F2', + 'border': 1, + 'border_color': '#D3D3D3', + 'align': 'center', + 'valign': 'vcenter' + }) + fmt_cell = wb.add_format({ + 'font_name': 'Calibri', + 'font_size': 11, + 'border': 1, + 'border_color': '#D3D3D3', + 'valign': 'vcenter', + 'align': 'left' + }) + + headers = list(df_scud.columns) + ws.set_row(3, 28) + for col_idx, header in enumerate(headers): + ws.write(0, col_idx, str(header), fmt_hdr) + + col_widths = [len(str(h)) for h in headers] + + for row_idx, row_values in enumerate(df_scud.values, start=1): + ws.set_row(row_idx, 19) + for col_idx, val in enumerate(row_values): + if pd.isna(val) or val is None: + val_str = "" + elif isinstance(val, bool): + val_str = "Да" if val else "Нет" + else: + val_str = str(val) + + ws.write(row_idx, col_idx, val_str, fmt_cell) + if len(val_str) > col_widths[col_idx]: + col_widths[col_idx] = len(val_str) + + for col_idx, width in enumerate(col_widths): + ws.set_column(col_idx, col_idx, min(max(width + 3, 10), 45)) + + safe_close_workbook(wb, output_path, target_dir, filename) \ No newline at end of file diff --git a/services/scud_etl/merger.py b/services/scud_etl/merger.py index fb40402..5bd3b82 100644 --- a/services/scud_etl/merger.py +++ b/services/scud_etl/merger.py @@ -2,6 +2,7 @@ =============================================================================== FILE: services/scud_etl/merger.py ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки. + Распределение исключений в общий рабочий пул при отсутствии справок. =============================================================================== """ @@ -17,10 +18,8 @@ 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, @@ -44,7 +43,6 @@ def load_identity_mappings() -> Dict[str, str]: def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame: - """Схлопывает дубликаты пропусков одного человека в СКУД.""" if df_scud is None or df_scud.empty: return df_scud @@ -53,7 +51,6 @@ def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame: 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)) @@ -95,10 +92,6 @@ def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame: 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() @@ -210,15 +203,20 @@ def merge_scud_and_1c( fio = row.get("fio_clean", "") 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: df_res.at[idx, "is_excluded"] = False continue dep = str(row.get("Подразделение", "")).upper() pos = str(row.get("Должность", "")).lower() - 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 + is_match_exc = (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 is_match_exc: + if has_official_absence: + df_res.at[idx, "is_excluded"] = False + else: + df_res.at[idx, "is_excluded"] = True mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == "")) df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение" @@ -228,21 +226,24 @@ def merge_scud_and_1c( def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: - active_df = df_merged[df_merged.get("is_excluded", False) == False].copy() - total_staff = len(active_df) + total_staff = len(df_merged) - came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа") - working_in_office = active_df[came_to_office_mask] - working_in_office_count = len(working_in_office) + came_to_office_mask = (df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (df_merged.get("is_excluded", False) == False) + exc_without_doc_mask = (df_merged.get("is_excluded", False) == True) & ( + df_merged["Вид_отсутствия"].isna() | + df_merged["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"]) + ) - df_not_came = active_df[~came_to_office_mask] + working_in_office_count = len(df_merged[came_to_office_mask | exc_without_doc_mask]) - reason_series = df_not_came["причина отсутствия"].astype(str).str.lower() + df_not_working = df_merged[~came_to_office_mask & ~exc_without_doc_mask] + + reason_series = df_not_working["причина отсутствия"].astype(str).str.lower() is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) - remote_home = df_not_came[is_remote_mask] + remote_home = df_not_working[is_remote_mask] remote_home_count = len(remote_home) - df_remaining_absent = df_not_came[~is_remote_mask] + df_remaining_absent = df_not_working[~is_remote_mask] has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ df_remaining_absent["причина отсутствия"].ne("") & \ df_remaining_absent["причина отсутствия"].ne("nan") & \ diff --git a/services/scud_export.py b/services/scud_export.py index c95c9d4..074ff26 100644 --- a/services/scud_export.py +++ b/services/scud_export.py @@ -1,6 +1,9 @@ """ -Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel. -Добавлена фиксация Первой Активности (без учета направления) и среза по времени. +=============================================================================== +FILE: services/scud_export.py +ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter). + Корректная фильтрация транзитных проходов турникетов парковки и двора. +=============================================================================== """ import argparse @@ -10,9 +13,14 @@ import sys import warnings from datetime import datetime, timedelta +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "..")) +if ROOT_DIR not in sys.path: + sys.path.insert(0, ROOT_DIR) + import pandas as pd import pyodbc -from openpyxl.utils import get_column_letter +import xlsxwriter from config import SCUD_DIR, clean_scud_fio_light, load_exceptions from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot @@ -31,12 +39,17 @@ logger.setLevel(logging.INFO) logger.propagate = False if not logger.handlers: - _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + try: + _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + _file_handler.setFormatter(_formatter) + logger.addHandler(_file_handler) + except (PermissionError, OSError) as e: + sys.stderr.write(f"Предупреждение: невозможно создать лог-файл {LOG_FILE}: {e}\n") + _console_handler = logging.StreamHandler(sys.stdout) _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - _file_handler.setFormatter(_formatter) _console_handler.setFormatter(_formatter) - logger.addHandler(_file_handler) logger.addHandler(_console_handler) @@ -73,8 +86,8 @@ WITH DailyLogs AS ( log.Event, log.Mode, CASE - WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT' - WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN' + WHEN log.Mode = 2 OR log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT' + WHEN log.Mode = 1 OR log.Event IN (1, 21, 26, 28, 32, 54, 64) THEN 'IN' ELSE 'OTHER' END AS Direction, ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc @@ -82,7 +95,7 @@ WITH DailyLogs AS ( WHERE log.TimeVal BETWEEN @StartDate AND @EndDate AND log.HozOrgan IS NOT NULL AND log.HozOrgan > 0 - AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65) + AND log.Event IN (1, 2, 21, 26, 27, 28, 29, 32, 33, 54, 55, 64, 65) ), Passages AS ( SELECT @@ -90,10 +103,22 @@ Passages AS ( MIN(TimeVal) AS FirstRawEvent, MAX(TimeVal) AS LastRawEvent, MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, - MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut, - MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType + -- Реальный выход: должен быть позже входа минимум на 5 минут (отсекает проезд через шлагбаум/парковку) + MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS RawLastOut FROM DailyLogs GROUP BY EmployeeID +), +EvaluatedPassages AS ( + SELECT + p.*, + CASE + WHEN p.RawLastOut IS NOT NULL + AND p.FirstIn IS NOT NULL + AND p.RawLastOut > DATEADD(MINUTE, 5, p.FirstIn) + THEN p.RawLastOut + ELSE NULL + END AS FilteredLastOut + FROM Passages p ) SELECT N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма], @@ -115,12 +140,8 @@ SELECT ELSE N'—' END AS [Первая_активность], CASE - WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) - THEN N'Нет выхода' - WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn - THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20)) - WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent) - THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20)) + WHEN pass.FilteredLastOut IS NOT NULL + THEN CAST(CONVERT(VARCHAR(8), pass.FilteredLastOut, 108) AS NVARCHAR(20)) ELSE N'Нет выхода' END AS [Конец_дня], CASE @@ -128,14 +149,16 @@ SELECT RIGHT('0' + CAST(DATEDIFF(MINUTE, ISNULL(pass.FirstIn, pass.FirstRawEvent), CASE - WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() - ELSE ISNULL(pass.LastOut, pass.LastRawEvent) + WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut + WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE() + ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent) END) / 60 AS VARCHAR), 2) + ':' + RIGHT('0' + CAST(DATEDIFF(MINUTE, ISNULL(pass.FirstIn, pass.FirstRawEvent), CASE - WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() - ELSE ISNULL(pass.LastOut, pass.LastRawEvent) + WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut + WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE() + ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent) END) % 60 AS VARCHAR), 2) ELSE N'00:00' END AS [Находился_в_здании], @@ -146,7 +169,7 @@ SELECT FROM pList p WITH (NOLOCK) LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID -LEFT JOIN Passages pass ON p.ID = pass.EmployeeID +LEFT JOIN EvaluatedPassages pass ON p.ID = pass.EmployeeID WHERE ISNULL(p.StatusRecord, 0) = 0 AND p.DateTimeInArchive IS NULL @@ -164,24 +187,54 @@ ORDER BY p.Name ASC; """ -def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): - from openpyxl import load_workbook +def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"): + workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False}) + worksheet = workbook.add_worksheet(sheet_name) - wb = load_workbook(file_path) - ws = wb[sheet_name] + fmt_header = workbook.add_format({ + 'bold': True, + 'bg_color': '#D9E1F2', + 'border': 1, + 'border_color': '#D3D3D3', + 'align': 'center', + 'valign': 'vcenter', + 'font_name': 'Calibri', + 'font_size': 11 + }) - for col_cells in ws.columns: - max_len = 0 - col_letter = get_column_letter(col_cells[0].column) - for cell in col_cells: - if cell.value is not None: - cell_len = len(str(cell.value)) - if cell_len > max_len: - max_len = cell_len - width = max(min_width, min(max_len + padding, max_width)) - ws.column_dimensions[col_letter].width = width + fmt_cell = workbook.add_format({ + 'border': 1, + 'border_color': '#D3D3D3', + 'valign': 'vcenter', + 'align': 'left', + 'font_name': 'Calibri', + 'font_size': 11 + }) - wb.save(file_path) + headers = list(df.columns) + col_widths = [len(str(h)) for h in headers] + + for col_idx, header in enumerate(headers): + worksheet.write(0, col_idx, str(header), fmt_header) + + for row_idx, row_values in enumerate(df.values, start=1): + for col_idx, val in enumerate(row_values): + if pd.isna(val) or val is None: + val_str = "" + elif isinstance(val, bool): + val_str = "Да" if val else "Нет" + else: + val_str = str(val) + + worksheet.write(row_idx, col_idx, val_str, fmt_cell) + + if len(val_str) > col_widths[col_idx]: + col_widths[col_idx] = len(val_str) + + for col_idx, width in enumerate(col_widths): + worksheet.set_column(col_idx, col_idx, min(max(width + 3, 10), 45)) + + workbook.close() def get_targets(input_date: str | None): @@ -230,8 +283,6 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo period_label = target["name"] is_yesterday = (period_label == "Вчера") - # ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE: - # Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL if is_yesterday and has_yesterday_final_snapshot(processing_date_str): log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.") continue @@ -273,8 +324,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo except OSError as e: log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") - df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") - auto_fit_columns(file_path, sheet_name="Отчет") + save_df_to_clean_excel(df, file_path, sheet_name="Отчет") log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") else: log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING") @@ -291,22 +341,10 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo if __name__ == "__main__": - help_text = """ -Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel. - -ПРИМЕРЫ ЗАПУСКА: - python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера - python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату - python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX - python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG) -""" - parser = argparse.ArgumentParser( - description=help_text, - formatter_class=argparse.RawDescriptionHelpFormatter - ) - parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)") - parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL") - parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/") + parser = argparse.ArgumentParser() + parser.add_argument("--date", dest="input_date", default=None) + parser.add_argument("-d", "--debug", action="store_true") + parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True) args = parser.parse_args() run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug) \ No newline at end of file diff --git a/services/share_copier.py b/services/share_copier.py index 03d796d..3f48369 100644 --- a/services/share_copier.py +++ b/services/share_copier.py @@ -1,3 +1,10 @@ +""" +=============================================================================== +FILE: services/share_copier.py +ROLE: Синхронизация файлов 1С (Штат и Отсутствия) с сетевой шары в data/1c/. +=============================================================================== +""" + import os import shutil from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR @@ -52,7 +59,9 @@ def copy_1c_files_from_share(): local_target_path = os.path.join(ZUP_1C_DIR, filename) try: - shutil.copy2(remote_file, local_target_path) + # shutil.copyfile копирует только содержимое потока байтов + # без попыток изменить POSIX-права/атрибуты (chmod) на CIFS/SMB шаре + shutil.copyfile(remote_file, local_target_path) print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/") copied_count += 1 except Exception as e: diff --git a/services/snapshots/retention.py b/services/snapshots/retention.py new file mode 100644 index 0000000..c374dae --- /dev/null +++ b/services/snapshots/retention.py @@ -0,0 +1,112 @@ +""" +=============================================================================== +FILE: services/snapshots/retention.py +ROLE: Политика ротации и очистки промежуточных почасовых срезов в SQLite. +=============================================================================== +""" + +import logging +from datetime import datetime, timedelta +from typing import List, Tuple +from core.connection import get_connection + +logger = logging.getLogger("SNAPSHOT_RETENTION") + + +def cleanup_old_intermediate_snapshots(days_to_keep_all: int = 2) -> int: + """ + Очищает промежуточные дневные срезы старше days_to_keep_all дней. + + Правило сохранения для архивных дней: + - Сохраняется срез Y (23:59:59). + - Сохраняется полуденный срез (ближайший к 13:00). + - Все остальные промежуточные снапшоты удаляются из scud_logs. + + Возвращает количество удаленных записей. + """ + cutoff_date = datetime.now() - timedelta(days=days_to_keep_all) + cutoff_str = cutoff_date.strftime("%Y-%m-%d") + + deleted_rows_count = 0 + + with get_connection() as conn: + cursor = conn.cursor() + + # 1. Получаем список всех уникальных дат старше cutoff_date + cursor.execute(""" + SELECT DISTINCT log_date + FROM scud_logs + WHERE snapshot_time IS NOT NULL + """) + all_dates = [row[0] for row in cursor.fetchall()] + + for raw_date in all_dates: + try: + # Преобразуем формат ДД.ММ.ГГГГ в объект даты + d_clean = raw_date.replace('_', '.') + dt_obj = datetime.strptime(d_clean, "%d.%m.%Y") + if dt_obj >= cutoff_date: + continue # Свежие дни не трогаем — там хранятся все срезы + + # 2. Для архивной даты ищем все снапшоты + cursor.execute(""" + SELECT DISTINCT snapshot_id, snapshot_time + FROM scud_logs + WHERE log_date = ? + """, (raw_date,)) + snapshots = cursor.fetchall() + + if len(snapshots) <= 2: + continue # Если и так 1-2 среза, ничего чистить не нужно + + keep_ids = set() + + # Ищем финальный срез Y / 23:59:59 / 22:00:00 + for s_id, s_time in snapshots: + s_id_str = str(s_id) + s_time_str = str(s_time) + if s_id_str.startswith('Y') or '23:59:59' in s_time_str or '22:00:00' in s_time_str: + keep_ids.add(s_id) + break + + # Ищем полуденный срез (ближайший к 13:00) + noon_target = datetime.strptime(f"{d_clean} 13:00:00", "%d.%m.%Y %H:%M:%S") + best_noon_id = None + min_noon_diff = float('inf') + + for s_id, s_time in snapshots: + if s_id in keep_ids: + continue + try: + raw_t = str(s_time).strip() + if '.' in raw_t.split()[0]: + dt_s = datetime.strptime(raw_t, "%d.%m.%Y %H:%M:%S") + else: + dt_s = datetime.strptime(raw_t, "%Y-%m-%d %H:%M:%S") + + diff = abs((dt_s - noon_target).total_seconds()) + if diff < min_noon_diff: + min_noon_diff = diff + best_noon_id = s_id + except Exception: + continue + + if best_noon_id: + keep_ids.add(best_noon_id) + + # 3. Все снапшоты, не попавшие в keep_ids, удаляем + to_delete_ids = [s_id for s_id, _ in snapshots if s_id not in keep_ids] + + for del_id in to_delete_ids: + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (del_id,)) + deleted_rows_count += cursor.rowcount + logger.info(f"Удален промежуточный архивный срез {del_id} за {raw_date}") + + except Exception as e: + logger.warning(f"Ошибка при обработке ротации за дату {raw_date}: {e}") + + conn.commit() + + if deleted_rows_count > 0: + logger.info(f"Ротация завершена: удалено {deleted_rows_count} строк промежуточных срезов.") + return deleted_rows_count \ No newline at end of file diff --git a/services/tasks/exporter.py b/services/tasks/exporter.py index 54307da..6debccd 100644 --- a/services/tasks/exporter.py +++ b/services/tasks/exporter.py @@ -3,99 +3,70 @@ FILE: services/tasks/exporter.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: services / tasks -ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP). - -AI-CONTEXT-ANCHORS: - - ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами. +ROLE: Экспорт задач в чистый Markdown и генерация прямой ссылки на скачивание. =============================================================================== """ import os import uuid -import logging -from datetime import datetime -from typing import Dict, Any, Optional, List -from config import OUTPUT_DIR +from typing import Dict, Any, Optional +from config import BASE_DIR from .repository import repo_get_tasks -logger = logging.getLogger("TASK_EXPORTER") +WEB_OUTPUT_DIR = os.path.join(BASE_DIR, "output", "web", "db_export_tasks_markdown") -# ANCHOR[TASK_EXPORT_MARKDOWN] -def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]: - """Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/.""" - tasks = repo_get_tasks(user_id) - if not tasks: - return {"status": "error", "message": "Список задач пуст, экспорт отменен"} +def export_tasks_to_markdown( + user_id: int, + filename: Optional[str] = "ROADMAP.md", + status_filter: Optional[str] = None +) -> Dict[str, Any]: + tasks = repo_get_tasks(user_id=user_id, status=status_filter) + safe_filename = os.path.basename(filename or "ROADMAP.md") + if not safe_filename.endswith(".md"): + safe_filename += ".md" - # 1. Фильтрация задач по статусу - if status_filter and status_filter.upper() != "ALL": - tgt = status_filter.upper() - if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]: - tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]] - elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]: - tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]] - elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]: - tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]] + session_uuid = str(uuid.uuid4())[:8] + target_dir = os.path.join(WEB_OUTPUT_DIR, session_uuid) + os.makedirs(target_dir, exist_ok=True) + target_filepath = os.path.join(target_dir, safe_filename) - if not tasks: - return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"} - - target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md" - if not target_filename.endswith(".md"): - target_filename = f"{target_filename}.md" - - now_str = datetime.now().strftime("%Y-%m-%d %H:%M") - - # 2. Группировка по модулям - modules: Dict[str, List[Dict[str, Any]]] = {} - for t in tasks: - mod = t.get("module") or "general" - modules.setdefault(mod, []).append(t) - - md_lines = [ - "# 🗺️ Дорожная карта задач проекта (ROADMAP)\n", - f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n", - "---\n" + lines = [ + f"# 📋 Реестр задач проекта ({safe_filename})", + f"**Всего задач:** {len(tasks)} ", + f"**Пользователь ID:** {user_id} ", + "", + "| ID | Статус | Приоритет | Модуль | Срок | Задача |", + "| :--- | :--- | :--- | :--- | :--- | :--- |" ] - for mod_name, mod_tasks in sorted(modules.items()): - md_lines.append(f"## Модуль `{mod_name}`\n") - for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)): - status = str(t.get("status", "BACKLOG")).upper() - is_done = status in ["COMPLETED", "DONE"] - is_progress = status in ["IN_PROGRESS", "PROGRESS"] - - check_box = "[x]" if is_done else "[ ]" - t_id = t.get("id") - title = t.get("title", "Без названия") - prio = t.get("priority", "MEDIUM") - due = f" *(срок: {t['due_date']})*" if t.get("due_date") else "" - status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "") + status_icons = { + "IN_PROGRESS": "⚙️ В работе", + "COMPLETED": "✓ Завершено", + "BACKLOG": "📋 Бэклог" + } - md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}") - - md_lines.append("\n---\n") + for t in tasks: + t_id = t.get("task_id") or f"#{t.get('id')}" + t_status = status_icons.get(t.get("status"), t.get("status", "BACKLOG")) + t_prio = t.get("priority", "MEDIUM") + t_mod = t.get("module", "general") + t_due = t.get("due_date") or "—" + t_title = str(t.get("title", "")).replace("|", "\\|").strip() + lines.append(f"| `{t_id}` | {t_status} | {t_prio} | `{t_mod}` | {t_due} | {t_title} |") - content = "\n".join(md_lines) + lines.append("") + content = "\n".join(lines) - # 3. Сохранение файла в изолированную сессионную папку - tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export") - os.makedirs(tool_dir, exist_ok=True) - - session_token = uuid.uuid4().hex[:8] - session_dir = os.path.join(tool_dir, session_token) - os.makedirs(session_dir, exist_ok=True) - - filepath = os.path.join(session_dir, target_filename) - with open(filepath, "w", encoding="utf-8") as f: + with open(target_filepath, "w", encoding="utf-8") as f: f.write(content) + download_url = f"/api/v1/files/download/db_export_tasks_markdown/{session_uuid}/{safe_filename}" + return { "status": "success", - "filename": target_filename, - "filepath": filepath, - "download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}", - "tasks_count": len(tasks), - "message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})." + "message": f"Отчет успешно сформирован в файл `{safe_filename}` (всего задач: {len(tasks)}).", + "filename": safe_filename, + "download_url": download_url, + "tasks_count": len(tasks) } \ No newline at end of file diff --git a/web_api_code_snapshot.md b/web_api_code_snapshot.md new file mode 100644 index 0000000..bfe6b45 --- /dev/null +++ b/web_api_code_snapshot.md @@ -0,0 +1,3310 @@ +# 🌐 WEB API & LLM AGENT CODE SNAPSHOT + +## File: `./modules/web_api/main.py` +```py +""" +=============================================================================== +FILE: modules/web_api/main.py +PROJECT: SCUD Orion AI (Unified Repository) +MODULE: web_api (Main Application Entry Point) +ROLE: Инициализация FastAPI приложения, подключение роутеров и статики. +=============================================================================== +""" + +# ANCHOR[APP_INIT_IMPORTS] +import os +import sys +import logging + +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +if CURRENT_DIR not in sys.path: + sys.path.insert(0, CURRENT_DIR) + +ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../")) + +for p in [ROOT_DIR, CURRENT_DIR]: + if p not in sys.path: + sys.path.insert(0, p) + +from fastapi import FastAPI, HTTPException +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse +from fastapi.exceptions import RequestValidationError + +from routers.auth import router as auth_router +from routers.admin import router as admin_router +from routers.tasks import router as tasks_router +from routers.chat import router as chat_router +from routers.files import router as files_router +from routers.exceptions import router as exceptions_router +from routers.snapshots import router as snapshots_router +from routers.remote_workers import router as remote_workers_router +from routers.context import router as context_router + +# ANCHOR[APP_CONFIG] +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler()] +) + +STATIC_DIR = os.path.join(CURRENT_DIR, "static") + +app = FastAPI(title="SCUD Orion AI Context API", version="2.5") + +if os.path.exists(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") + return JSONResponse( + status_code=422, + content={"detail": exc.errors(), "body": str(exc)} + ) + +# ANCHOR[ROUTER_REGISTRATION] +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(tasks_router) +app.include_router(chat_router) +app.include_router(files_router) +app.include_router(exceptions_router) +app.include_router(snapshots_router) +app.include_router(remote_workers_router) +app.include_router(context_router) + +# ANCHOR[ROOT_STATIC_ROUTES] +@app.get("/") +def read_root(): + index_path = os.path.join(STATIC_DIR, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + raise HTTPException(status_code=404, detail="Frontend index.html not found") + +@app.get("/favicon.ico") +async def favicon(): + file_path = os.path.join(STATIC_DIR, "favicon.ico") + if os.path.exists(file_path): + return FileResponse(file_path) + raise HTTPException(status_code=404) + +@app.get("/{file_path:path}") +def serve_static_fallback(file_path: str): + clean_path = file_path.lstrip("/") + + # Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов + forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"] + if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns): + raise HTTPException(status_code=403, detail="Access denied") + + target = os.path.join(STATIC_DIR, clean_path) + + if os.path.isfile(target): + if clean_path.endswith(".js"): + return FileResponse(target, media_type="application/javascript") + elif clean_path.endswith(".css"): + return FileResponse(target, media_type="text/css") + return FileResponse(target) + + filename = os.path.basename(clean_path) + for root, _, files in os.walk(STATIC_DIR): + if filename in files: + full_path = os.path.join(root, filename) + media = "application/javascript" if filename.endswith(".js") else "text/css" + return FileResponse(full_path, media_type=media) + + raise HTTPException(status_code=404, detail="File not found") +``` + +## File: `./modules/web_api/routers/chat.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/chat.py +ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов, + поддержкой Function Calling, Fast-Path и оптического распознавания OCR. +=============================================================================== +""" + +import os +import shutil +import base64 +import logging +from typing import Optional, List, Dict, Any + +from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form +from pydantic import BaseModel + +from llm.agent import process_chat_message +from config import BASE_DIR + +logger = logging.getLogger("CHAT_API") +router = APIRouter(prefix="/api/v1", tags=["Chat"]) + +UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads") +os.makedirs(UPLOAD_TMP_DIR, exist_ok=True) + + +class ChatMessageRequest(BaseModel): + message: str + session_id: Optional[str] = "web_session_main" + user_id: Optional[int] = 1 + + +def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int: + if explicit_user_id and explicit_user_id > 0: + return explicit_user_id + + if authorization and authorization.startswith("Bearer "): + token = authorization.replace("Bearer ", "").strip() + if token.isdigit(): + return int(token) + elif token.startswith("dev_token_"): + try: + return int(token.replace("dev_token_", "")) + except ValueError: + pass + return 1 + + +@router.post("/chat") +async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)): + user_id = resolve_user_id(authorization, payload.user_id) + session_id = payload.session_id or "web_session_main" + user_msg = payload.message.strip() + + if not user_msg: + raise HTTPException(status_code=400, detail="Пустое сообщение") + + logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}") + + reply_text, history, action_payload = process_chat_message( + user_id=user_id, + user_message=user_msg, + session_id=session_id + ) + + return { + "status": "success", + "user_id": user_id, + "session_id": session_id, + "response": reply_text, + "action_payload": action_payload + } + + +@router.post("/chat/upload") +async def chat_upload_endpoint( + file: UploadFile = File(...), + message: Optional[str] = Form(""), + session_id: Optional[str] = Form("web_session_main"), + authorization: Optional[str] = Header(None) +): + user_id = resolve_user_id(authorization, 1) + file_path = os.path.join(UPLOAD_TMP_DIR, file.filename) + + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + file_context = "" + image_b64 = None + fn_lower = file.filename.lower() + + # 1. Текстовые форматы + if fn_lower.endswith((".txt", ".csv", ".log", ".md")): + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + file_context = f.read(6000) + except Exception as e: + logger.warning(f"Не удалось прочитать текст: {e}") + + # 2. Изображения (прямой OCR) + elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")): + try: + with open(file_path, "rb") as f: + image_b64 = base64.b64encode(f.read()).decode("utf-8") + except Exception as e: + logger.warning(f"Ошибка кодирования картинки в base64: {e}") + + # 3. PDF документы (текстовый слой + рендеринг скана при необходимости) + elif fn_lower.endswith(".pdf"): + # Попытка извлечь встроенный текстовый слой + try: + import pypdf + reader = pypdf.PdfReader(file_path) + extracted = [] + for page in reader.pages: + t = page.extract_text() + if t: + extracted.append(t) + file_context = "\n".join(extracted).strip() + except Exception: + pass + + # Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR + if len(file_context) < 40: + try: + import fitz # PyMuPDF + doc = fitz.open(file_path) + if len(doc) > 0: + page = doc[0] + pix = page.get_pixmap(dpi=150) + img_bytes = pix.tobytes("png") + image_b64 = base64.b64encode(img_bytes).decode("utf-8") + file_context = "" + except Exception as e: + logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}") + + user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}" + + reply_text, history, action_payload = process_chat_message( + user_id=user_id, + user_message=user_msg, + file_context=file_context, + image_b64=image_b64, + session_id=session_id + ) + + return { + "status": "success", + "user_id": user_id, + "session_id": session_id, + "response": reply_text, + "action_payload": action_payload + } +``` + +## File: `./modules/web_api/routers/auth.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/auth.py +ROLE: Аутентификация, валидация JWT-токенов и управление паролями. +=============================================================================== +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Optional + +import jwt +from passlib.context import CryptContext +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel + +from core.connection import get_connection + +JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" +ALGORITHM = "HS256" + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + + +def get_db(): + return get_connection(row_factory=True) + + +def create_access_token(user_id: int, username: str, is_admin: bool) -> str: + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(days=30) + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + username = payload.get("username") + is_admin = bool(payload.get("is_admin", False)) + return {"id": user_id, "username": username, "is_admin": is_admin} + except Exception as e: + logging.warning(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен авторизации", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class AuthRequest(BaseModel): + username: str + password: str + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +@router.post("/login") +def login(req: AuthRequest): + username = req.username.strip().lower() + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,)) + user = cursor.fetchone() + conn.close() + + if not user or not pwd_context.verify(req.password, user["password_hash"]): + raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") + + is_admin = bool(user["is_admin"]) or (user["username"] == "puh") + token = create_access_token(user["id"], user["username"], is_admin) + + # Возвращаем full_name (если не задано — отдаем username) + full_name = user["full_name"] if user["full_name"] else user["username"] + + return { + "status": "success", + "token": token, + "username": user["username"], + "full_name": full_name, + "user_id": user["id"], + "is_admin": is_admin + } + + +@router.post("/change-password") +def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not req.new_password or len(req.new_password) < 4: + raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) + user = cursor.fetchone() + + if not user or not pwd_context.verify(req.old_password, user["password_hash"]): + conn.close() + raise HTTPException(status_code=400, detail="Неверный старый пароль") + + new_hash = pwd_context.hash(req.new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) + conn.commit() + conn.close() + + return {"status": "success", "message": "Пароль успешно изменен"} +``` + +## File: `./modules/web_api/routers/tasks.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/tasks.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / routers +ROLE: REST API эндпоинты реестра задач. +=============================================================================== +""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, Dict, Any + +from routers.auth import get_current_user +from services.tasks.service import get_tasks, add_task, update_task_details + +router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"]) + + +class TaskCreateRequest(BaseModel): + title: str + priority: Optional[str] = "MEDIUM" + module: Optional[str] = "general" + due_date: Optional[str] = None + status: Optional[str] = "BACKLOG" + + +class TaskUpdateRequest(BaseModel): + title: Optional[str] = None + priority: Optional[str] = None + due_date: Optional[str] = None + status: Optional[str] = None + + +def resolve_user_id(current_user: Dict[str, Any]) -> int: + if not current_user: + return 1 + return current_user.get("id") or current_user.get("user_id") or 1 + + +@router.get("") +async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) + return {"tasks": get_tasks(user_id=user_id, status=status)} + + +@router.post("") +async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) + res = add_task( + user_id=user_id, + module=req.module, + title=req.title, + priority=req.priority, + due_date=req.due_date, + status=req.status + ) + if "error" in res: + raise HTTPException(status_code=400, detail=res["error"]) + return res + + +@router.patch("/{task_id}") +async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) + res = update_task_details( + user_id=user_id, + task_id=task_id, + title=req.title, + priority=req.priority, + status=req.status, + due_date=req.due_date + ) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res +``` + +## File: `./modules/web_api/routers/exceptions.py` +```py +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, Dict, List +from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db + +router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"]) + + +class ExceptionItem(BaseModel): + category: str + value: str + comment: Optional[str] = "" + + +@router.get("/") +def api_get_exceptions(): + return get_all_exceptions_from_db() + + +@router.post("/") +def api_add_exception(item: ExceptionItem): + if not add_exception_to_db(item.category, item.value, item.comment): + raise HTTPException(status_code=400, detail="Ошибка добавления исключения") + return {"status": "success", "data": item} + + +@router.delete("/") +def api_delete_exception(category: str, value: str): + if not remove_exception_from_db(category, value): + raise HTTPException(status_code=404, detail="Исключение не найдено") + return {"status": "success"} +``` + +## File: `./modules/web_api/routers/admin.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/admin.py +ROLE: Администрирование пользователей и прав доступа. +=============================================================================== +""" + +# ANCHOR[ADMIN_ROUTER_IMPORTS] +import logging +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user, get_db, pwd_context + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + +# ANCHOR[ADMIN_SCHEMAS] +class CreateUserRequest(BaseModel): + username: str + password: str + full_name: Optional[str] = None + is_admin: Optional[bool] = False + +# ANCHOR[ADMIN_ENDPOINTS] +@router.get("/users") +def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + +@router.post("/users") +def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + username = req.username.strip().lower() + if not username or not req.password: + raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") + + pwd_hash = pwd_context.hash(req.password) + full_name = req.full_name.strip() if req.full_name else None + is_admin = 1 if req.is_admin else 0 + + cursor.execute( + "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", + (username, pwd_hash, full_name, is_admin) + ) + conn.commit() + conn.close() + + logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") + return {"status": "success", "message": f"Пользователь {username} создан"} + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + if user_id == current_user["id"]: + raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logging.info(f"Удален пользователь ID: {user_id}") + return {"status": "success", "message": "Пользователь удален"} +``` + +## File: `./modules/web_api/routers/files.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/files.py +ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен + через изолированные UUID-директории инструментов. +=============================================================================== +""" + +import os +import time +import shutil +import urllib.parse +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +router = APIRouter(prefix="/api/v1/files", tags=["Files"]) + +BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web") +os.makedirs(WEB_OUTPUT_DIR, exist_ok=True) + +SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок + + +def purge_old_tool_sessions(tool_dir_path: str): + """Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента.""" + if not os.path.exists(tool_dir_path): + return + now = time.time() + cutoff = now - (SESSION_TTL_HOURS * 3600) + try: + for entry in os.listdir(tool_dir_path): + subpath = os.path.join(tool_dir_path, entry) + if os.path.isdir(subpath): + if os.path.getmtime(subpath) < cutoff: + shutil.rmtree(subpath, ignore_errors=True) + except Exception: + pass + + +@router.get("/download/{tool_name}/{session_uuid}/{filename}") +async def download_file(tool_name: str, session_uuid: str, filename: str): + """ + Безопасная отдача файла с каноническим именем из изолированной директории. + """ + safe_tool = os.path.basename(tool_name) + safe_uuid = os.path.basename(session_uuid) + safe_filename = os.path.basename(filename) + + file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename) + + if not os.path.exists(file_path) or not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек") + + # Определение MIME-типа + media_type = "application/octet-stream" + if safe_filename.endswith(".md") or safe_filename.endswith(".txt"): + media_type = "text/markdown; charset=utf-8" + elif safe_filename.endswith(".xlsx"): + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + elif safe_filename.endswith(".pdf"): + media_type = "application/pdf" + + # Корректная кодировка для кириллических имен файлов + encoded_filename = urllib.parse.quote(safe_filename) + + return FileResponse( + path=file_path, + media_type=media_type, + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + } + ) +``` + +## File: `./modules/web_api/llm/agent.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/agent.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm (Core Agent Coordinator) +ROLE: Нативный оркестратор диалога, диспетчер Function Calling, + передача активных срезов в контекст модели и терминальные вызовы. +=============================================================================== +""" + +import sys +import json +import logging +from typing import List, Dict, Any, Tuple, Optional + +from .db.connection import get_db_connection +from .db_tools import ( + db_get_active_system_prompt, + db_apply_prompt_node_action, + db_get_tool_action, + db_get_tasks, + db_update_task_details, + db_delete_task, + db_add_task, + db_tasks_edit, + db_export_tasks_markdown, + db_get_rules, + db_set_session_state, + db_get_session_state, + db_clear_session_state, + db_get_snapshots, + db_delete_snapshots, + db_get_current_server_time, + db_save_chat_message, + db_get_chat_history, + db_purge_ephemeral_messages, + db_get_stats, + db_get_anomalies, + db_get_reference +) + +from .schemas import TOOLS_SCHEMA +from .core.calendar_utils import get_dynamic_calendar_context +from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed +from .core.ollama_client import call_ollama_chat +from .core.fast_path import handle_fast_path_intercept +from .core.context_manager import ( + save_tool_interaction, + save_dialog_interaction, + mark_last_user_message_ephemeral, + close_tool_session_and_cleanup +) + +logger = logging.getLogger("SCUD_AGENT") +logger.setLevel(logging.INFO) +logger.propagate = False + +if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + + +def process_chat_message( + user_id: int, + user_message: str, + file_context: str = "", + image_b64: Optional[str] = None, + chat_history: List[Dict[str, Any]] = None, + session_id: str = "web_session_main" +) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: + """ + Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling. + """ + logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") + + session_state = db_get_session_state(session_id) + if not session_state: + db_purge_ephemeral_messages(session_id) + + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message + + # 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM + fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) + if fast_path_res: + return fast_path_res + + # 2. Фиксация сообщения пользователя + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_history = db_get_chat_history(session_id, limit=20) + + calendar_context = get_dynamic_calendar_context() + user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + + current_state_type = session_state.get("state_type") if session_state else None + state_data = session_state.get("data_json") or {} if session_state else {} + if not isinstance(state_data, dict): + state_data = {} + idle_turns = state_data.get("idle_turns", 0) + + active_state_context = "" + if current_state_type == "PROMPT_PREVIEW": + active_state_context = "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n- Открыт предпросмотр изменений промпта.\n" + elif current_state_type == "SNAPSHOTS_VIEW": + active_date = state_data.get("query_date", "выбранную дату") + active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n" + elif current_state_type == "SNAPSHOT_INSPECT": + snap_id = state_data.get("snapshot_id", "") + snap_date = state_data.get("log_date", "") + records = state_data.get("records", []) + + lines = [] + for r in records: + st = "Присутствовал" if r.get("is_present") else "Отсутствовал" + lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}") + + dump_str = "\n".join(lines) + active_state_context = ( + f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n" + f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, их времени входа/выхода, отделах или присутствии — " + f"ТЫ ОБЯЗАН брать данные исключительно из этого списка активного среза:\n{dump_str}\n" + ) + + # ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам + system_prompt_content = ( + f"Ты — интеллектуальный ассистент SCUD Orion AI.\n" + f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n" + f"СТРОГИЕ ПРАВИЛА:\n" + f"1. К оператору всегда обращайся на Вы.\n" + f"2. Для работы с данными системы ВСЕГДА вызывай соответствующие инструменты (tools):\n" + f" - Снапшоты и срезы СКУД -> db_get_snapshots\n" + f" - Удаление срезов -> db_delete_snapshots\n" + f" - Задачи и бэклог -> db_get_tasks, db_tasks_edit\n" + f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n" + f" - База знаний и регламенты -> db_get_rules\n" + f" - Аномалии СКУД/1С -> db_get_anomalies\n" + f" - Справка -> db_get_reference\n" + f"3. ЗАДАЧИ:\n" + f" - При любых запросах на просмотр задач (включая опечатки вроде 'змдачи', 'таски', 'дела', 'покажи задачи') — " + f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n" + f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! " + f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n" + f"4. ПРАВИЛА И РЕГЛАМЕНТЫ: При любых вопросах о правилах компании или арбитраже ТЫ ОБЯЗАН СРАЗУ вызвать db_get_rules.\n" + f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n" + f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы " + f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n" + f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" + f"- Пользователь: {user_info}\n" + f"- {calendar_context}\n" + f"{active_state_context}" + ) + + user_msg_object = {"role": "user", "content": full_user_content} + + try: + if image_b64: + user_msg_object["images"] = [image_b64] + messages = [ + {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."}, + user_msg_object + ] + msg = call_ollama_chat(messages, is_vision=True) + else: + clean_db_history = [dict(m) for m in db_history] + for m in clean_db_history: + m.pop("images", None) + messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] + msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) + + raw_text_reply = msg.get("content", "") + tool_calls = msg.get("tool_calls", []) + + if not tool_calls: + tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) + + if tool_calls: + logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") + + tool = tool_calls[0] + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + if isinstance(fn_args, str): + try: + fn_args = json.loads(fn_args) + except Exception: + fn_args = {} + + logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") + + # Ротация контекста + close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}") + session_state = None + mark_last_user_message_ephemeral(session_id) + + # 1. Задачи (Просмотр) + if fn_name == "db_get_tasks": + raw_tasks = db_get_tasks(user_id, status=fn_args.get("status")) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + # 2. Единый диспетчер задач + elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]: + action = fn_args.get("action", "UPDATE").upper() + if fn_name == "db_add_task": action = "ADD" + elif fn_name == "db_delete_task": action = "DELETE" + elif fn_name == "db_update_task_status": action = "UPDATE" + + if action == "DELETE": + task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip() + db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0}) + reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_DELETE_CONFIRM", + "buttons": [ + {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} + ] + } + + elif action == "EXPORT": + export_res = db_export_tasks_markdown( + user_id=user_id, + filename=fn_args.get("filename") or "ROADMAP.md", + status_filter=fn_args.get("status") + ) + reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.") + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + + action_payload = None + if export_res.get("status") == "success": + action_payload = { + "type": "FILE_DOWNLOAD_CARD", + "filename": export_res.get("filename"), + "download_url": export_res.get("download_url"), + "tasks_count": export_res.get("tasks_count") + } + return reply_text, db_get_chat_history(session_id), action_payload + + else: + res = db_tasks_edit( + user_id=user_id, + action=action, + task_id=fn_args.get("task_id"), + title=fn_args.get("title"), + priority=fn_args.get("priority", "MEDIUM"), + status=fn_args.get("status"), + module=fn_args.get("module", "general"), + due_date=fn_args.get("due_date") + ) + raw_tasks = db_get_tasks(user_id) + reply_text = res.get("message", "Операция над задачами выполнена.") + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + # 3. Системный промпт + elif fn_name == "db_get_system_prompt": + active_prompt = db_get_active_system_prompt() + reply_text = ( + "📋 **АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ:**\n\n" + f"```text\n{active_prompt}\n```\n\n" + "Вы можете добавить, отредактировать или удалить любой пункт." + ) + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "PROMPT_VIEW", + "buttons": [ + {"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"}, + {"label": "База знаний", "value": "покажи правила компании", "style": "secondary"} + ] + } + + # 4. База знаний и правила компании (терминальный возврат) + elif fn_name == "db_get_rules": + rules_data = db_get_rules() + if isinstance(rules_data, dict) and "rules" in rules_data: + rules_list = rules_data["rules"] + elif isinstance(rules_data, list): + rules_list = rules_data + else: + rules_list = [str(rules_data)] + + formatted_rules = "\n\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)]) + reply_text = ( + "📖 **БАЗА ЗНАНИЙ И ПРАВИЛА КОМПАНИИ (КАДРОВЫЙ АРБИТРАЖ):**\n\n" + f"```text\n{formatted_rules}\n```" + ) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "RULES_VIEW", + "buttons": [ + {"label": "✏️ Редактировать правила", "value": "action:open_rules_editor", "style": "primary"}, + {"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"} + ] + } + + # 5. Снапшоты СКУД + elif fn_name == "db_get_snapshots": + snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) + query_date = snapshots_res.get("query_date", "выбранную дату") + reply_text = f"Реестр срезов СКУД за {query_date}:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": snapshots_res + } + + elif fn_name == "db_delete_snapshots": + raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str") + raw_ids = fn_args.get("snapshot_ids") or [] + is_confirmed = fn_args.get("confirmed", False) + + if raw_id and not raw_ids: + if isinstance(raw_id, str) and "," in raw_id: + raw_ids = [s.strip() for s in raw_id.split(",")] + else: + raw_ids = [raw_id] + + safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")] + if not safe_ids: + reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None + + if not is_confirmed: + query_date = state_data.get("query_date", "") + db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", { + "snapshot_ids": safe_ids, + "query_date": query_date, + "idle_turns": 0 + }) + ids_str = ", ".join(safe_ids) + reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOT_DELETE_CONFIRM", + "buttons": [ + {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} + ] + } + else: + db_delete_snapshots(snapshot_ids=safe_ids) + query_date = state_data.get("query_date", "") + updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date) + + reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_snapshots_res + } + + # 6. Прочие сервисные инструменты + elif fn_name == "db_get_current_server_time": + tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + elif fn_name == "db_get_stats": + tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + elif fn_name == "db_get_anomalies": + tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + elif fn_name == "db_get_reference": + tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + else: + tool_result_content = "{}" + + messages.append(msg) + messages.append({"role": "tool", "content": tool_result_content}) + sec_msg = call_ollama_chat(messages, is_vision=False) + raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") + final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен." + + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) + return final_content, db_get_chat_history(session_id), None + + # Свободный диалог (Topic Drift) + raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") + final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) + return final_reply, db_get_chat_history(session_id), None + + except Exception as ex: + logger.exception(f"Непредвиденная ошибка агента: {ex}") + error_reply = f"Внутренняя ошибка сервера: {ex}" + return error_reply, db_get_chat_history(session_id), None +``` + +## File: `./modules/web_api/llm/db_tools.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/db_tools.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm +ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade). + +AI-CONTEXT-ANCHORS: + - ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API. +=============================================================================== +""" + +# ANCHOR[FACADE_EXPORTS] +from typing import Optional, List, Dict, Any +from core.connection import DB_PATH, get_connection as get_db_connection + +# Домен: Задачи +from services.tasks.service import ( + get_tasks as db_get_tasks, + add_task as db_add_task, + update_task_details as db_update_task_details, + delete_task as db_delete_task, + execute_task_action as db_tasks_edit +) +# Защитный алиас для обратной совместимости +db_update_task_status = db_update_task_details + +from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown +from services.tasks.repository import normalize_task_id + +# Домен: Системный промпт +from services.prompts.service import ( + get_active_system_prompt as db_get_active_system_prompt, + apply_prompt_action as db_apply_prompt_node_action, + save_full_prompt_draft, + create_prompt_preview +) + +# Домен: База знаний +from services.knowledge.service import ( + get_rules as db_get_rules, + add_rule as db_add_rule +) + +# Чат, сессии, статистика +from .db.db_chat import ( + db_save_chat_message, + db_get_chat_history, + db_purge_ephemeral_messages, + db_clear_chat_history +) +from .db.db_prompts import ( + db_get_tool_action, + db_set_session_state, + db_get_session_state, + db_clear_session_state, + db_get_stats, + db_get_anomalies, + db_get_reference +) +from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time + + +def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None: + """Совместимая обертка для сохранения системного промпта.""" + if draft_text is not None: + save_full_prompt_draft(draft_text, prompt_name=name_or_text) + else: + save_full_prompt_draft(name_or_text, prompt_name="main_agent") + + +def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]: + """Совместимый фасад выборки снапшотов с сохранением стейта сессии.""" + from services.snapshots.service import get_snapshots_registry + from .core.calendar_utils import parse_relative_date_ru + + clean_date = (date_str or "").strip() + if not clean_date and original_user_message: + clean_date = parse_relative_date_ru(original_user_message) + + res = get_snapshots_registry(date_str=clean_date if clean_date else None) + db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res) + return res + + +def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]: + """Совместимый фасад безопасного удаления снапшотов.""" + from services.snapshots.service import delete_snapshots_safely + + target_ids = [] + if snapshot_ids: + target_ids.extend(snapshot_ids) + if snapshot_id: + if isinstance(snapshot_id, str) and "," in snapshot_id: + target_ids.extend([s.strip() for s in snapshot_id.split(",")]) + else: + target_ids.append(snapshot_id) + + return delete_snapshots_safely(snapshot_ids=target_ids) +``` + +## File: `./modules/web_api/llm/schemas.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/schemas.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm +ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama. + +AI-CONTEXT-ANCHORS: + - ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта. + - ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами. + - ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД. +=============================================================================== +""" + +# ANCHOR[SCHEMA_PROMPT_EDIT] +TOOLS_SCHEMA = [ + { + "type": "function", + "function": { + "name": "db_prompt_node_edit", + "description": ( + "Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n" + "Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n" + "При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"], + "description": "Тип действия" + }, + "section_id": { + "type": "integer", + "description": "Номер раздела (например 1)" + }, + "item_id": { + "type": "integer", + "description": "Номер пункта (например 8)" + }, + "nodes_list": { + "type": "array", + "items": {"type": "string"}, + "description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']" + }, + "content": { + "type": "string", + "description": "Текст пункта" + } + }, + "required": ["action"] + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_system_prompt", + "description": "Просмотр текущего активного системного промпта ассистента из базы данных.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + # ANCHOR[SCHEMA_TASKS_EDIT] + { + "type": "function", + "function": { + "name": "db_get_tasks", + "description": ( + "Просмотр реестра задач и бэклога текущего пользователя.\n" + "Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n" + "Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}." + ), + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"], + "description": "Опциональный фильтр статуса задач (по умолчанию ALL)" + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_tasks_edit", + "description": ( + "Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n" + "СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n" + "- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n" + "- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n" + "- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n" + "- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["ADD", "UPDATE", "DELETE", "EXPORT"], + "description": "Тип действия: ADD, UPDATE, DELETE или EXPORT" + }, + "task_id": { + "type": "string", + "description": "Номер задачи (например: '37')" + }, + "title": { + "type": "string", + "description": "Описание или текст задачи" + }, + "status": { + "type": "string", + "enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"], + "description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)" + }, + "priority": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], + "description": "Приоритет задачи" + }, + "module": { + "type": "string", + "description": "Модуль проекта" + }, + "due_date": { + "type": "string", + "description": "Срок в формате ГГГГ-ММ-ДД" + }, + "filename": { + "type": "string", + "description": "Имя файла для экспорта" + } + }, + "required": ["action"] + } + } + }, + # ANCHOR[SCHEMA_SNAPSHOTS] + { + "type": "function", + "function": { + "name": "db_get_snapshots", + "description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.", + "parameters": { + "type": "object", + "properties": { + "date_str": { + "type": "string", + "description": "Дата в формате ДД.ММ.ГГГГ или относительное слово" + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_delete_snapshots", + "description": "Удаление снапшотов СКУД по идентификатору или дате.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": { + "type": "string", + "description": "Идентификатор конкретного снапшота" + }, + "day_str": { + "type": "string", + "description": "Дата всех снапшотов за день" + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_anomalies", + "description": "Просмотр истории аномалий и расхождений между СКУД и 1С.", + "parameters": { + "type": "object", + "properties": { + "date_str": { + "type": "string", + "description": "Опциональная дата в формате ДД.ММ.ГГГГ" + }, + "limit": { + "type": "integer", + "description": "Лимит записей" + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_rules", + "description": "Просмотр базы знаний и правил кадрового арбитража компании.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_stats", + "description": "Получение статистики количества записей в таблицах базы данных.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_current_server_time", + "description": "Получение текущего точного времени сервера.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_reference", + "description": "Справка о возможностях ассистента и примеры доступных команд.", + "parameters": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Категория справки" + } + } + } + } + } +] +``` + +## File: `./modules/web_api/llm/core/context_manager.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/context_manager.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций + инструментов и содержательного диалога с сохранением Topic Drift. +=============================================================================== +""" + +import logging +from typing import Dict, Any, Optional, List + +from ..db.connection import get_db_connection +from ..db_tools import ( + db_save_chat_message, + db_get_chat_history, + db_purge_ephemeral_messages, + db_clear_session_state, + db_set_session_state +) + +logger = logging.getLogger("CONTEXT_MANAGER") + + +def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None: + """ + Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1). + При очистке удалятся оба сообщения, не оставляя сирот. + """ + db_save_chat_message(session_id, "user", user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1) + + +def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None: + """ + Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0). + Эти сообщения остаются в истории навсегда (включая Topic Drift). + """ + db_save_chat_message(session_id, "user", user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0) + + +def mark_last_user_message_ephemeral(session_id: str) -> None: + """Помечает последнее сообщение пользователя как эфемерное при активации инструмента.""" + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE chat_messages + SET is_ephemeral = 1 + WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') + """, (session_id,)) + conn.commit() + + +def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int: + """ + Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды. + Весь содержательный диалог сохраняется. + """ + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}") + return deleted_count +``` + +## File: `./modules/web_api/llm/core/tool_injector.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/tool_injector.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Базовая санитарная очистка вывода и тегов инструментов. +=============================================================================== +""" + +import re +import logging +from typing import List, Dict, Any + +logger = logging.getLogger("TOOL_INJECTOR") + + +def clean_raw_tool_tags(text: str) -> str: + """Удаляет сырые теги вызова инструментов и системный шум.""" + if not text: + return "" + cleaned = re.sub(r'.*?', '', text, flags=re.DOTALL) + cleaned = re.sub(r'<\|.*?\|>', '', cleaned) + return cleaned.strip() + + +def clean_output(text: str) -> str: + """Очищает маркеры форматирования.""" + return text.strip() if text else "" + + +def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Модель управляется через TOOLS_SCHEMA и системный контекст. + Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP. + """ + return existing_tool_calls +``` + +## File: `./modules/web_api/llm/core/ollama_client.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/ollama_client.py +ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama. +=============================================================================== +""" + +# ANCHOR[OLLAMA_CLIENT_IMPORTS] +import json +import urllib.request +import urllib.error +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger("OLLAMA_CLIENT") + +OLLAMA_URL = "http://10.121.17.227:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" + +LLM_OPTIONS = { + "num_predict": 8192, + "num_ctx": 8192, + "temperature": 0.0, + "repeat_penalty": 1.0, + "presence_penalty": 0.0, + "top_p": 0.9 +} + + +# ANCHOR[OLLAMA_REQUEST_DISPATCHER] +def call_ollama_chat( + messages: list, + tools: Optional[list] = None, + is_vision: bool = False, + timeout: int = 120 +) -> Dict[str, Any]: + """ + Отправляет подготовленный массив сообщений в API Ollama. + Возвращает разобранный словарь сообщения ответа или генерирует исключение. + """ + model_name = VISION_MODEL if is_vision else TEXT_MODEL + payload = { + "model": model_name, + "messages": messages, + "stream": False, + "options": LLM_OPTIONS + } + + if tools and not is_vision: + payload["tools"] = tools + + req = urllib.request.Request( + OLLAMA_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=timeout) as response: + res_data = json.loads(response.read().decode("utf-8")) + return res_data.get("message", {}) +``` + +## File: `./modules/web_api/llm/core/fast_path.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/fast_path.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью. +=============================================================================== +""" + +import difflib +import logging +from typing import Dict, Any, Tuple, Optional + +from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt +from services.knowledge.service import get_rules, add_rule +from services.tasks.service import get_tasks, delete_task, execute_task_action +from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely + +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages +from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state +from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup + +logger = logging.getLogger("FAST_PATH") + + +def _generate_prompt_diff_html(baseline_text: str, draft_text: str) -> str: + base_lines = [line.rstrip() for line in baseline_text.strip().splitlines()] + draft_lines = [line.rstrip() for line in draft_text.strip().splitlines()] + + matcher = difflib.SequenceMatcher(None, base_lines, draft_lines) + diff_html_lines = [] + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == 'equal': + for line in base_lines[i1:i2]: + diff_html_lines.append(line) + elif tag == 'delete': + for line in base_lines[i1:i2]: + diff_html_lines.append( + f'{line} [УДАЛЕНИЕ]' + ) + elif tag == 'insert': + for line in draft_lines[j1:j2]: + diff_html_lines.append( + f'{line}' + ) + elif tag == 'replace': + for line in base_lines[i1:i2]: + diff_html_lines.append( + f'{line} [УДАЛЕНИЕ]' + ) + for line in draft_lines[j1:j2]: + diff_html_lines.append( + f'{line}' + ) + + return "\n".join(diff_html_lines) + + +def handle_fast_path_intercept( + session_id: str, + user_message: str, + full_user_content: str, + session_state: Optional[Dict[str, Any]] +) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: + msg_raw = user_message.strip() + msg_lower = msg_raw.lower() + + # ------------------------------------------------------------------------- + # 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ) + # ------------------------------------------------------------------------- + if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]): + filename = "ROADMAP.md" + if " в " in msg_lower: + parts = msg_raw.split(" в ", 1)[1].strip().split() + if parts and ("." in parts[0] or parts[0].endswith("md")): + filename = parts[0] + + res = execute_task_action(user_id=1, action="EXPORT", filename=filename) + reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.") + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + + action_payload = { + "type": "FILE_DOWNLOAD_CARD", + "filename": res.get("filename", filename), + "download_url": res.get("download_url", "#"), + "tasks_count": res.get("tasks_count", 0) + } + return reply, db_get_chat_history(session_id), action_payload + + # ------------------------------------------------------------------------- + # 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА) + # ------------------------------------------------------------------------- + if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "): + target_snap_id = msg_raw.split()[-1].replace("#", "").strip() + from core.database import load_scud_from_db_by_snapshot + + df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id) + if df_snap is None or df_snap.empty: + reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных." + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + return reply, db_get_chat_history(session_id), None + + total_cnt = len(df_snap) + present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0 + absent_cnt = total_cnt - present_cnt + + snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—' + log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—' + + rows_list = [] + for _, r in df_snap.iterrows(): + rows_list.append({ + "fio": r.get('Сотрудник', r.get('fio', '')), + "department": r.get('Подразделение', r.get('department_scud', '—')), + "time_in": r.get('Начало_дня', 'Нет входа'), + "first_activity": r.get('Первая_активность', '—'), + "time_out": r.get('Конец_дня', 'Нет выхода'), + "in_building": r.get('Находился_в_здании', '00:00'), + "is_present": bool(r.get('Пришел', False)), + "anomaly": r.get('anomaly_flag', 'NONE') + }) + + # Фиксация среза в памяти сессии для последующих вопросов к LLM + db_set_session_state(session_id, "SNAPSHOT_INSPECT", { + "snapshot_id": target_snap_id, + "log_date": log_date, + "snapshot_time": snap_time, + "records": rows_list, + "idle_turns": 0 + }) + + reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})." + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + + return reply, db_get_chat_history(session_id), { + "type": "SNAPSHOT_INSPECT_CARD", + "snapshot_id": target_snap_id, + "log_date": log_date, + "snapshot_time": snap_time, + "total_count": total_cnt, + "present_count": present_cnt, + "absent_count": absent_cnt, + "records": rows_list + } + + # ------------------------------------------------------------------------- + # 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА + # ------------------------------------------------------------------------- + if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]: + active_prompt = get_active_system_prompt() + state_data = session_state.get("data_json") if session_state else {} + draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": draft_text, + "action": "MANUAL_EDIT", + "idle_turns": 0 + }) + reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:" + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + return reply, db_get_chat_history(session_id), { + "type": "PROMPT_EDITOR", + "raw_draft": draft_text, + "baseline_prompt": active_prompt + } + + # ------------------------------------------------------------------------- + # 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ + # ------------------------------------------------------------------------- + if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]: + rules_data = get_rules() + rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)]) + raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)]) + + db_set_session_state(session_id, "RULES_PREVIEW", { + "draft_text": raw_rules_text, + "action": "MANUAL_EDIT_RULES", + "idle_turns": 0 + }) + reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:" + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + return reply, db_get_chat_history(session_id), { + "type": "RULES_EDITOR", + "raw_draft": raw_rules_text, + "baseline_prompt": raw_rules_text + } + + # ------------------------------------------------------------------------- + # 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ + # ------------------------------------------------------------------------- + if msg_raw.startswith("action:save_draft_prompt:::"): + new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip() + active_prompt = get_active_system_prompt() + diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content) + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": new_draft_content, + "action": "MANUAL_EDIT", + "idle_turns": 0 + }) + + preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените." + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return preview_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": new_draft_content, + "baseline_prompt": active_prompt, + "diff_html": diff_html, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } + + # ------------------------------------------------------------------------- + # 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ + # ------------------------------------------------------------------------- + if msg_raw.startswith("action:save_draft_rules:::"): + new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip() + rules_data = get_rules() + rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)]) + baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)]) + diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content) + + db_set_session_state(session_id, "RULES_PREVIEW", { + "draft_text": new_draft_content, + "action": "MANUAL_EDIT_RULES", + "idle_turns": 0 + }) + + preview_reply = f"Предпросмотр изменений базы знаний компании:\n\n{diff_html}\n\nПодтвердите сохранение или отмените действие." + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return preview_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": new_draft_content, + "baseline_prompt": baseline_rules, + "diff_html": diff_html, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"} + ] + } + + if not session_state: + return None + + state_type = session_state.get("state_type") + state_data = session_state.get("data_json") or {} + if not isinstance(state_data, dict): + state_data = {} + + # ------------------------------------------------------------------------- + # 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА + # ------------------------------------------------------------------------- + if state_type == "PROMPT_PREVIEW": + is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"] + is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"] + + if is_confirm: + draft_text = state_data.get("draft_text", "") + if draft_text: + save_full_prompt_draft(draft_text, prompt_name="main_agent") + + close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY") + db_clear_session_state(session_id) + + reply = "✅ Системный промпт успешно сохранен и применен в базе данных." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None + + elif is_cancel: + close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED") + db_clear_session_state(session_id) + + reply = "❌ Изменения системного промпта отменены." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None + + # ------------------------------------------------------------------------- + # 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ + # ------------------------------------------------------------------------- + elif state_type == "RULES_PREVIEW": + is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"] + is_cancel = msg_lower in ["отмена", "отменить", "нет"] + + if is_confirm: + draft_text = state_data.get("draft_text", "") + lines = [l.strip() for l in draft_text.splitlines() if l.strip()] + for line in lines: + clean_line = line + if line[0].isdigit() and "." in line[:5]: + clean_line = line.split(".", 1)[1].strip() + add_rule(clean_line) + + close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS") + db_clear_session_state(session_id) + + reply = "✅ База знаний и правила компании успешно сохранены в базе данных." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None + + elif is_cancel: + close_tool_session_and_cleanup(session_id, close_reason="RULES_EDIT_CANCELLED") + db_clear_session_state(session_id) + + reply = "❌ Изменение правил компании отменено." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None + + return None +``` + +## File: `./modules/web_api/static/js/app.js` +```js +const AUTH_TOKEN_KEY = "scud_api_auth_token"; +const SESSION_ID = "web_session_main"; +const STORAGE_KEY = "scud_chat_input_history"; + +let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || ""; +let CURRENT_USERNAME = localStorage.getItem("scud_username") || ""; +let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true"; +let IS_GUEST = localStorage.getItem("scud_is_guest") === "true"; + +let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); +let historyIndex = -1; + +document.addEventListener("DOMContentLoaded", () => { + const userInputEl = document.getElementById("user-input"); + + if (userInputEl) { + userInputEl.addEventListener("input", function() { + this.style.height = "24px"; + const newHeight = Math.min(this.scrollHeight, 120); + this.style.height = newHeight + "px"; + }); + } + + if (IS_GUEST) { + hideAuthModal(); + updateUIState(); + } else if (API_TOKEN) { + hideAuthModal(); + updateUIState(); + if (typeof loadTasks === "function") { + loadTasks(); + } + } else { + showAuthModal(); + } +}); +``` + +## File: `./modules/web_api/static/js/auth.js` +```js +/** + * =============================================================================== + * FILE: modules/web_api/static/js/auth.js + * ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков. + * =============================================================================== + */ + +const AUTH_STORAGE_KEY = "scud_auth_token"; +const USERNAME_STORAGE_KEY = "scud_username"; +const FULLNAME_STORAGE_KEY = "scud_full_name"; +const IS_ADMIN_STORAGE_KEY = "scud_is_admin"; +const USER_ID_STORAGE_KEY = "scud_user_id"; + +const AuthManager = { + getToken() { + return localStorage.getItem(AUTH_STORAGE_KEY) || ""; + }, + + getUserId() { + const uid = localStorage.getItem(USER_ID_STORAGE_KEY); + return uid ? parseInt(uid, 10) : 1; + }, + + getUsername() { + return localStorage.getItem(USERNAME_STORAGE_KEY) || ""; + }, + + getFullName() { + return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь"; + }, + + isAdmin() { + return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true"; + }, + + isAuthenticated() { + return Boolean(this.getToken()); + }, + + setSession(token, username, fullName, isAdmin, userId = 1) { + localStorage.setItem(AUTH_STORAGE_KEY, token); + localStorage.setItem(USERNAME_STORAGE_KEY, username); + localStorage.setItem(FULLNAME_STORAGE_KEY, fullName || username); + localStorage.setItem(IS_ADMIN_STORAGE_KEY, String(isAdmin)); + localStorage.setItem(USER_ID_STORAGE_KEY, String(userId)); + localStorage.setItem("scud_api_auth_token", token); + localStorage.setItem("auth_token", token); + }, + + getAuthHeaders() { + const token = this.getToken(); + const headers = { "Content-Type": "application/json" }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + return headers; + }, + + logout() { + console.log("[Auth] Полный выход из системы..."); + localStorage.clear(); + sessionStorage.clear(); + + document.cookie.split(";").forEach((cookie) => { + const eqPos = cookie.indexOf("="); + const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`; + }); + + window.location.href = "/"; + } +}; + +window.AuthManager = AuthManager; +window.logout = () => AuthManager.logout(); +``` + +## File: `./modules/web_api/static/js/tasks.js` +```js +/** + * =============================================================================== + * FILE: modules/web_api/static/js/tasks.js + * ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер). + * =============================================================================== + */ + +let currentTasksFilter = 'ALL'; +let tasksCache = []; + +function getTasksContainer() { + return document.getElementById("tasks-list-container") || + document.getElementById("sidebar-dynamic-content") || + document.getElementById("tasks-list"); +} + +async function loadTasks() { + const container = getTasksContainer(); + if (!container) return; + + container.innerHTML = ` +
+ Загрузка задач... +
+ `; + + try { + const res = await fetch("/api/v1/tasks", { + headers: AuthManager.getAuthHeaders() + }); + + if (!res.ok) { + if (res.status === 401) { + showAuthModal(); + return; + } + throw new Error(`Ошибка сервера (${res.status})`); + } + + const data = await res.json(); + tasksCache = Array.isArray(data) ? data : (data.tasks || []); + renderTasksUI(); + } catch (err) { + console.error("[Tasks] Ошибка загрузки:", err); + container.innerHTML = ` +
+ + Не удалось загрузить задачи + +
+ `; + } +} + +function setTaskFilter(filter) { + currentTasksFilter = filter; + renderTasksUI(); +} + +function renderTasksUI() { + const container = getTasksContainer(); + if (!container) return; + + let filtered = tasksCache; + if (currentTasksFilter === 'IN_PROGRESS') { + filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS'); + } else if (currentTasksFilter === 'BACKLOG') { + filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED'); + } else if (currentTasksFilter === 'COMPLETED') { + filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE'); + } + + const filtersHtml = ` +
+ + + + +
+ `; + + const addBtnHtml = ` +
+ Задачи: ${filtered.length} + +
+ `; + + if (filtered.length === 0) { + container.innerHTML = ` + ${filtersHtml} + ${addBtnHtml} +
+ Нет задач в выбранной категории +
+ `; + return; + } + + const itemsHtml = filtered.map(t => { + const isDone = t.status === 'COMPLETED' || t.status === 'DONE'; + const priorityColors = { + 'HIGH': 'bg-rose-50 text-rose-700 border-rose-200', + 'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200', + 'LOW': 'bg-slate-50 text-slate-600 border-slate-200' + }; + const pClass = priorityColors[t.priority] || priorityColors['MEDIUM']; + + return ` +
+
+
+ + ${escapeHtml(t.title)} +
+ ${t.priority || 'NORMAL'} +
+ +
+ ${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')} +
+ +
+
+
+ `; + }).join(''); + + container.innerHTML = ` + ${filtersHtml} + ${addBtnHtml} +
+ ${itemsHtml} +
+ `; +} + +async function toggleTaskStatus(id, currentStatus) { + const newStatus = (currentStatus === 'COMPLETED' || currentStatus === 'DONE') ? 'IN_PROGRESS' : 'COMPLETED'; + try { + const res = await fetch(`/api/v1/tasks/${id}`, { + method: "PATCH", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ status: newStatus }) + }); + if (res.ok) { + loadTasks(); + } + } catch (e) { + console.error("Ошибка смены статуса задачи:", e); + } +} + +async function deleteTaskItem(id) { + if (!confirm("Удалить эту задачу?")) return; + try { + const res = await fetch(`/api/v1/tasks/${id}`, { + method: "DELETE", + headers: AuthManager.getAuthHeaders() + }); + if (res.ok) { + loadTasks(); + } + } catch (e) { + alert("Ошибка сети при удалении"); + } +} + +async function openCreateTaskModal() { + const title = prompt("Введите описание новой задачи:"); + if (!title || !title.trim()) return; + + try { + const res = await fetch("/api/v1/tasks", { + method: "POST", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ + title: title.trim(), + priority: "MEDIUM", + status: "IN_PROGRESS" + }) + }); + if (res.ok) { + loadTasks(); + } else { + alert("Не удалось создать задачу"); + } + } catch (e) { + alert("Ошибка сети"); + } +} + +window.loadTasks = loadTasks; +window.setTaskFilter = setTaskFilter; +``` + +## File: `./modules/web_api/static/js/chat/core.js` +```js +/** + * =============================================================================== + * FILE: modules/web_api/static/js/chat/core.js + * ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, прецизионный скролл + * вопроса к верху окна, авто-высота инпута, крупный шрифт text-sm. + * =============================================================================== + */ + +let currentAttachedFile = null; + +const STORAGE_KEY = "scud_chat_input_history"; +let chatInputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); +let chatHistoryIndex = -1; +let temporaryCurrentInput = ""; + +function saveCommandToHistory(commandText) { + if (!commandText || !commandText.trim()) return; + const cleanCmd = commandText.trim(); + if (cleanCmd.startsWith("action:save_draft_")) return; + + chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd); + chatInputHistory.push(cleanCmd); + if (chatInputHistory.length > 50) chatInputHistory.shift(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(chatInputHistory)); + chatHistoryIndex = -1; +} + +// Поиск скролл-контейнера +function getActualScrollContainer() { + const listEl = document.getElementById("chat-messages-container"); + if (!listEl) return window; + + let parent = listEl; + while (parent && parent !== document.body) { + const style = window.getComputedStyle(parent); + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { + return parent; + } + parent = parent.parentElement; + } + return document.querySelector("main") || window; +} + +// Прецизионный скролл сообщения оператора к верхнему краю экрана +function scrollToUserMessageTop(element) { + if (!element) return; + + setTimeout(() => { + const scroller = getActualScrollContainer(); + if (scroller && scroller !== window) { + const scrollerRect = scroller.getBoundingClientRect(); + const elRect = element.getBoundingClientRect(); + const currentScroll = scroller.scrollTop; + const targetScroll = currentScroll + (elRect.top - scrollerRect.top) - 16; + + scroller.scrollTo({ + top: Math.max(targetScroll, 0), + behavior: 'smooth' + }); + } else { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }, 80); +} + +function scrollToBottom() { + const scroller = getActualScrollContainer(); + if (scroller && scroller !== window) { + scroller.scrollTop = scroller.scrollHeight; + } +} + +function updateInputHeightAndFade(textarea) { + if (!textarea) return; + + if (!textarea.value || textarea.value.trim() === '') { + textarea.style.height = '22px'; + textarea.style.overflowY = 'hidden'; + textarea.style.maskImage = 'none'; + textarea.style.webkitMaskImage = 'none'; + return; + } + + textarea.style.height = 'auto'; + const minHeight = 22; + const maxHeight = 110; + const currentScrollHeight = textarea.scrollHeight; + + if (currentScrollHeight <= minHeight + 2) { + textarea.style.height = minHeight + 'px'; + textarea.style.overflowY = 'hidden'; + textarea.style.maskImage = 'none'; + textarea.style.webkitMaskImage = 'none'; + } else if (currentScrollHeight > maxHeight) { + textarea.style.height = maxHeight + 'px'; + textarea.style.overflowY = 'auto'; + textarea.style.maskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)'; + textarea.style.webkitMaskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)'; + } else { + textarea.style.height = currentScrollHeight + 'px'; + textarea.style.overflowY = 'hidden'; + textarea.style.maskImage = 'none'; + textarea.style.webkitMaskImage = 'none'; + } +} + +function appendUserMessage(text, filename = null) { + const container = document.getElementById("chat-messages-container"); + if (!container) return; + + const msgId = 'user-msg-' + Date.now(); + let fileBadge = ''; + if (filename) { + fileBadge = ` +
+ + ${escapeHtml(filename)} +
+ `; + } + + const msgHtml = ` +
+
+ ${fileBadge} +
${escapeHtml(text)}
+
+
+ +
+
+ `; + container.insertAdjacentHTML("beforeend", msgHtml); + + const userEl = document.getElementById(msgId); + scrollToUserMessageTop(userEl); +} + +function appendAssistantLoading() { + const container = document.getElementById("chat-messages-container"); + if (!container) return null; + + const loadingId = 'loading-' + Date.now(); + const html = ` +
+
+ +
+
+
+ + ИИ обрабатывает запрос... +
+
+
+ `; + container.insertAdjacentHTML("beforeend", html); + return loadingId; +} + +function appendAssistantMessage(text, buttons = [], actionPayload = null) { + const container = document.getElementById("chat-messages-container"); + if (!container) return; + + let payloadHtml = ''; + let isHtmlBody = false; + + if (actionPayload) { + if (actionPayload.type === 'SNAPSHOTS_CARD' && typeof renderSnapshotsCard === 'function') { + payloadHtml = renderSnapshotsCard(actionPayload.data); + } else if (actionPayload.type === 'TASK_INTERACTIVE_CARD' && typeof renderInteractiveTaskCard === 'function') { + payloadHtml = renderInteractiveTaskCard(actionPayload.tasks); + } else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') { + const dlUrl = actionPayload.download_url || '#'; + const fName = actionPayload.filename || 'ROADMAP.md'; + const count = actionPayload.tasks_count || ''; + payloadHtml = ` +
+
+
+ +
+
+
${escapeHtml(fName)}
+
Задач выгружено: ${count} шт. · Markdown
+
+
+ + + Скачать файл + +
+ `; + } else if (actionPayload.type === 'SNAPSHOT_INSPECT_CARD') { + const records = actionPayload.records || []; + const inspectTableId = 'inspect-table-' + Date.now(); + const searchInputId = 'inspect-search-' + Date.now(); + + const rowsHtml = records.map((r, idx) => ` + + ${idx + 1} + ${escapeHtml(r.fio)} + ${escapeHtml(r.department)} + ${escapeHtml(r.time_in)} + ${escapeHtml(r.first_activity)} + ${escapeHtml(r.time_out)} + ${escapeHtml(r.in_building)} + ${r.is_present ? '✓ Да' : 'Нет'} + + `).join(''); + + payloadHtml = ` +
+
+
+ Срез #${escapeHtml(actionPayload.snapshot_id)} + · Всего: ${records.length} чел. (Присутствуют: ${actionPayload.present_count || 0}) +
+
+ + +
+
+
+ + + + + + + + + + + + + + ${rowsHtml} +
№СотрудникОтделВходАктивностьВыходВ зданииСтатус
+
+
+ `; + } else if (actionPayload.type === 'PROMPT_EDITOR') { + const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || ''; + const editorId = 'prompt-editor-' + Date.now(); + payloadHtml = ` +
+
+ Инлайн-редактор системного промпта: + Прямое редактирование текста +
+ +
+ + +
+
+ `; + } else if (actionPayload.type === 'RULES_EDITOR') { + const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || ''; + const editorId = 'rules-editor-' + Date.now(); + payloadHtml = ` +
+
+ Редактор базы знаний и правил компании: + Прямое изменение правил кадрового арбитража +
+ +
+ + +
+
+ `; + } else if (actionPayload.type === 'PROMPT_PREVIEW') { + isHtmlBody = true; + } + } + + let buttonsHtml = ''; + if (!actionPayload || (actionPayload.type !== 'PROMPT_EDITOR' && actionPayload.type !== 'RULES_EDITOR')) { + const allButtons = (buttons && buttons.length > 0) ? buttons : (actionPayload && actionPayload.buttons ? actionPayload.buttons : []); + if (allButtons && Array.isArray(allButtons) && allButtons.length > 0) { + buttonsHtml = ` +
+ ${allButtons.map(b => ` + + `).join('')} +
+ `; + } + } + + const bodyContent = isHtmlBody ? text : escapeHtml(text); + + const html = ` +
+
+ +
+
+
ИИ-ассистент SCUD Orion AI
+
${bodyContent}
+ ${payloadHtml} + ${buttonsHtml} +
+
+ `; + container.insertAdjacentHTML("beforeend", html); +} + +window.filterInspectTable = function(tableId, query) { + const table = document.getElementById(tableId); + if (!table) return; + const q = (query || '').trim().toLowerCase(); + const rows = table.querySelectorAll('.inspect-row'); + rows.forEach(r => { + const fio = r.getAttribute('data-fio') || ''; + const dept = r.getAttribute('data-dept') || ''; + if (!q || fio.includes(q) || dept.includes(q)) { + r.classList.remove('hidden'); + } else { + r.classList.add('hidden'); + } + }); +}; + +window.sendChatAction = function(actionText) { + if (!actionText || !actionText.trim()) return; + const input = document.getElementById("user-input"); + if (input) { + input.value = actionText.trim(); + updateInputHeightAndFade(input); + } + window.sendMessage(); +}; + +window.submitPromptDraftToDiff = function(editorId) { + const textarea = document.getElementById(editorId); + if (!textarea) return; + const newText = textarea.value.trim(); + if (!newText) { + alert("Текст системного промпта не может быть пустым"); + return; + } + + const command = `action:save_draft_prompt:::${newText}`; + const input = document.getElementById("user-input"); + if (input) input.value = command; + window.sendMessage(); +}; + +window.submitRulesDraftToDiff = function(editorId) { + const textarea = document.getElementById(editorId); + if (!textarea) return; + const newText = textarea.value.trim(); + if (!newText) { + alert("Правила не могут быть пустыми"); + return; + } + + const command = `action:save_draft_rules:::${newText}`; + const input = document.getElementById("user-input"); + if (input) input.value = command; + window.sendMessage(); +}; + +window.sendMessage = async function() { + const input = document.getElementById("user-input"); + if (!input) return; + + const messageText = input.value.trim(); + const fileToSend = currentAttachedFile; + + if (!messageText && !fileToSend) return; + + saveCommandToHistory(messageText); + + input.value = ""; + input.style.height = '22px'; + input.style.overflowY = 'hidden'; + input.style.maskImage = 'none'; + input.style.webkitMaskImage = 'none'; + window.clearAttachedFile(); + + if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) { + appendUserMessage( + messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''), + fileToSend ? fileToSend.name : null + ); + } else { + appendUserMessage("Сформировать предпросмотр изменений"); + } + + const loadingId = appendAssistantLoading(); + + try { + let res; + if (fileToSend) { + const formData = new FormData(); + formData.append("file", fileToSend); + formData.append("message", messageText); + formData.append("session_id", "web_session_main"); + + res = await fetch("/api/v1/chat/upload", { + method: "POST", + headers: { + "Authorization": AuthManager.getAuthHeaders()["Authorization"] + }, + body: formData + }); + + if (res.status === 404) { + res = await fetch("/api/v1/chat", { + method: "POST", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ + message: messageText || `Загружен файл: ${fileToSend.name}`, + session_id: "web_session_main" + }) + }); + } + } else { + res = await fetch("/api/v1/chat", { + method: "POST", + headers: AuthManager.getAuthHeaders(), + body: JSON.stringify({ + message: messageText, + session_id: "web_session_main" + }) + }); + } + + const loaderEl = document.getElementById(loadingId); + if (loaderEl) loaderEl.remove(); + + if (res.ok) { + const data = await res.json(); + const replyText = data.response || data.text || data.message || "Запрос выполнен."; + const buttons = data.buttons || []; + const actionPayload = data.action_payload || null; + + appendAssistantMessage(replyText, buttons, actionPayload); + + if (window.SidebarManager) { + SidebarManager.renderContent(); + } + } else { + const err = await res.json().catch(() => ({})); + appendAssistantMessage(`⚠️ Ошибка сервера (${res.status}): ${err.detail || "Не удалось получить ответ"}`); + } + } catch (err) { + console.error("Ошибка отправки сообщения:", err); + const loaderEl = document.getElementById(loadingId); + if (loaderEl) loaderEl.remove(); + appendAssistantMessage("⚠️ Ошибка соединения с сервером при отправке сообщения."); + } +}; + +window.clearAttachedFile = function() { + currentAttachedFile = null; + const preview = document.getElementById("file-attachment-preview"); + const nameEl = document.getElementById("file-attachment-name"); + const fileInput = document.getElementById("file-upload-input"); + + if (preview) preview.classList.add("hidden"); + if (nameEl) nameEl.innerText = ""; + if (fileInput) fileInput.value = ""; +}; + +function handleFileSelected(file) { + if (!file) return; + currentAttachedFile = file; + const preview = document.getElementById("file-attachment-preview"); + const nameEl = document.getElementById("file-attachment-name"); + + if (preview && nameEl) { + nameEl.innerText = file.name; + preview.classList.remove("hidden"); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const input = document.getElementById("user-input"); + const fileInput = document.getElementById("file-upload-input"); + const overlay = document.getElementById("global-drag-overlay"); + + if (input) { + input.style.lineHeight = '22px'; + input.style.height = '22px'; + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + if (e.shiftKey) return; + e.preventDefault(); + window.sendMessage(); + return; + } + + if (e.key === "ArrowUp") { + const isSingleLine = !input.value.includes("\n"); + const isAtBeginning = input.selectionStart === 0 && input.selectionEnd === 0; + + if (isSingleLine || isAtBeginning) { + if (chatInputHistory.length > 0) { + e.preventDefault(); + if (chatHistoryIndex === -1) { + temporaryCurrentInput = input.value; + chatHistoryIndex = chatInputHistory.length - 1; + } else if (chatHistoryIndex > 0) { + chatHistoryIndex--; + } + input.value = chatInputHistory[chatHistoryIndex]; + updateInputHeightAndFade(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + } else if (e.key === "ArrowDown") { + const isSingleLine = !input.value.includes("\n"); + const isAtEnd = input.selectionStart === input.value.length; + + if (isSingleLine || isAtEnd) { + if (chatHistoryIndex !== -1) { + e.preventDefault(); + if (chatHistoryIndex < chatInputHistory.length - 1) { + chatHistoryIndex++; + input.value = chatInputHistory[chatHistoryIndex]; + } else { + chatHistoryIndex = -1; + input.value = temporaryCurrentInput; + } + updateInputHeightAndFade(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + } + }); + + input.addEventListener("input", function() { + updateInputHeightAndFade(this); + chatHistoryIndex = -1; + }); + } + + if (fileInput) { + fileInput.addEventListener("change", (e) => { + if (e.target.files && e.target.files[0]) { + handleFileSelected(e.target.files[0]); + } + }); + } + + // ⭐️ Глобальный Drag-and-Drop на всё окно браузера + let dragCounter = 0; + + window.addEventListener('dragenter', (e) => { + e.preventDefault(); + dragCounter++; + if (overlay) { + overlay.classList.remove('hidden'); + } + }, false); + + window.addEventListener('dragleave', (e) => { + e.preventDefault(); + dragCounter--; + if (dragCounter <= 0) { + dragCounter = 0; + if (overlay) { + overlay.classList.add('hidden'); + } + } + }, false); + + window.addEventListener('dragover', (e) => { + e.preventDefault(); + }, false); + + window.addEventListener('drop', (e) => { + e.preventDefault(); + dragCounter = 0; + if (overlay) { + overlay.classList.add('hidden'); + } + + const dt = e.dataTransfer; + if (dt && dt.files && dt.files[0]) { + handleFileSelected(dt.files[0]); + } + }, false); +}); +``` + +## File: `./modules/web_api/static/js/chat/task_widget.js` +```js +/* +=============================================================================== +FILE: modules/web_api/static/js/chat/task_widget.js +ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами. +=============================================================================== +*/ + +window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS'; +window.currentTasksCache = window.currentTasksCache || []; + +function getAuthHeaders() { + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const headers = { 'Content-Type': 'application/json' }; + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } + return headers; +} + +window.sendChatAction = function(actionText) { + const input = document.getElementById("user-input"); + if (input && typeof sendMessage === "function") { + input.value = actionText; + if (typeof setInputLocked === "function") setInputLocked(false); + sendMessage(); + } +}; + +// ============================================================================ +// РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ +// ============================================================================ +function renderSnapshotsCard(data) { + if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return ''; + const queryDate = data.query_date || 'выбранную дату'; + const snapshots = data.snapshots; + + if (snapshots.length === 0) { + return ` +
+ 📸 За дату ${queryDate} сохраненных снапшотов не найдено. +
+ `; + } + + const rowsHtml = snapshots.map((s, idx) => { + const snapId = s.snapshot_id || `ID-${idx}`; + const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—'; + const count = s.record_count || 0; + const isFinal = snapId.startsWith('Y'); + + if (isFinal) { + return ` +
+
+ + + + + #${snapId} + +
+ + ${snapTime} + + + ${count} записей + +
+
+ + Итоговый Y-срез + +
+ `; + } + + return ` +
+
+ + + #${snapId} + +
+ + ${snapTime} + + + ${count} записей + +
+
+ +
+ `; + }).join(''); + + return ` +
+
+
+
+ +
+
+ Реестр срезов СКУД + за ${queryDate} +
+
+
+ + + Срезов: ${snapshots.length} + +
+
+ +
+ ${rowsHtml} +
+ + + +
+ `; +} + +// ⭐️ Обработчики чекбоксов +window.toggleSelectAllSnapshots = function(masterCb) { + const root = masterCb.closest('.snapshots-widget-root'); + if (!root) return; + const checkboxes = root.querySelectorAll('.snapshot-item-checkbox'); + checkboxes.forEach(cb => cb.checked = masterCb.checked); + window.syncBulkDeleteFooter(root); +}; + +window.updateSelectedSnapshots = function(itemCb) { + const root = itemCb.closest('.snapshots-widget-root'); + if (!root) return; + window.syncBulkDeleteFooter(root); +}; + +window.syncBulkDeleteFooter = function(root) { + const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked'); + const footer = root.querySelector('.snapshot-bulk-actions-footer'); + const counter = root.querySelector('.bulk-selected-counter'); + const masterCb = root.querySelector('.select-all-snapshots-cb'); + const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox'); + + if (masterCb) { + masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length; + } + + if (checkboxes.length > 0) { + if (footer) footer.classList.remove('hidden'); + if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`; + } else { + if (footer) footer.classList.add('hidden'); + } +}; + +// Одиночная корзина в строке снапшота: +// onclick="window.sendChatAction('удали снапшот ${snapId}')" + +// Кнопка пакетного удаления в подвале карточки: +window.submitBulkDeleteSnapshots = function(btnEl) { + const root = btnEl.closest('.snapshots-widget-root'); + if (!root) return; + const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value); + if (selected.length === 0) return; + window.sendChatAction(`удали снапшоты ${selected.join(', ')}`); +}; + +// ============================================================================ +// РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD) +// ============================================================================ +function renderInteractiveTaskCard(tasks) { + if (!tasks || !Array.isArray(tasks)) return ''; + window.currentTasksCache = tasks; + + const counts = { + ALL: tasks.length, + IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length, + PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length, + COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length + }; + + const currentFilter = window.activeTaskFilter || 'IN_PROGRESS'; + + const filteredTasks = tasks.filter(t => { + const s = (t.status || 'BACKLOG').toUpperCase(); + if (currentFilter === 'ALL') return true; + if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS'; + if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED'; + if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE'; + return true; + }); + + const getPrioBadge = (prio) => { + const p = (prio || 'MEDIUM').toUpperCase(); + if (p === 'HIGH' || p === 'CRITICAL') return '🔥 HIGH'; + if (p === 'LOW') return '☕ LOW'; + return '⚡ MEDIUM'; + }; + + const getStatusBadge = (status) => { + const s = (status || 'BACKLOG').toUpperCase(); + if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '⚙️ В работе'; + if (s === 'COMPLETED' || s === 'DONE') return '✓ Готово'; + return '📋 В планах'; + }; + + const taskRows = filteredTasks.map(t => { + const id = t.id; + const title = t.title || 'Без названия'; + const isDone = t.status === 'COMPLETED' || t.status === 'DONE'; + const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS'; + + const actionBtn = isInProgress + ? `` + : (!isDone + ? `` + : ''); + + return ` +
+
+
+ #${id} + ${title} +
+
+ ${actionBtn} + + +
+
+ +
+ ${getPrioBadge(t.priority)} + ${getStatusBadge(t.status)} + ${t.module || 'general'} + ${t.due_date ? `📅 срок: ${t.due_date}` : ''} + ${t.created_at ? `создана: ${t.created_at.split(' ')[0]}` : ''} +
+ + + +
+ `; + }).join(''); + + const emptyState = ` +
+
📭
+
Нет задач в категории «${currentFilter}»
+
+ `; + + return ` +
+
+
+ + + + +
+ + +
+ + + +
+ ${filteredTasks.length > 0 ? taskRows : emptyState} +
+
+ `; +} + +window.switchTaskFilter = function(filterName, btnEl) { + window.activeTaskFilter = filterName; + const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); + if (root && window.currentTasksCache && window.currentTasksCache.length > 0) { + root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache); + } +}; + +window.toggleCreateTaskForm = function(btnEl) { + const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); + if (!root) return; + const form = root.querySelector('.new-task-creation-form'); + if (form) { + form.classList.toggle('hidden'); + if (!form.classList.contains('hidden')) { + const input = form.querySelector('.new-task-title'); + if (input) input.focus(); + } + } +}; + +window.submitCreateTask = async function(btnEl) { + const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); + if (!root) return; + + const titleInput = root.querySelector('.new-task-title'); + const dateInput = root.querySelector('.new-task-date'); + const prioInput = root.querySelector('.new-task-prio'); + const statusInput = root.querySelector('.new-task-status'); + + if (!titleInput || !titleInput.value.trim()) { + alert('Введите описание задачи'); + return; + } + + try { + const res = await fetch('/api/v1/tasks', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + title: titleInput.value.trim(), + due_date: dateInput ? (dateInput.value || null) : null, + priority: prioInput ? prioInput.value : 'MEDIUM', + status: statusInput ? statusInput.value : 'BACKLOG' + }) + }); + + if (res.ok) { + window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED'; + window.sendChatAction('покажи задачи'); + } else { + const err = await res.json(); + alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка')); + } + } catch (e) { + console.error('Ошибка создания задачи:', e); + alert('Сетевая ошибка при создании задачи'); + } +}; + +window.openTaskInlineEditor = function(id) { + const card = document.getElementById(`task-card-${id}`); + if (!card) return; + card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden')); + const editor = document.getElementById(`task-editor-${id}`); + if (editor) editor.classList.remove('hidden'); +}; + +window.closeTaskInlineEditor = function(id) { + const card = document.getElementById(`task-card-${id}`); + if (!card) return; + card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden')); + const editor = document.getElementById(`task-editor-${id}`); + if (editor) editor.classList.add('hidden'); +}; + +window.saveTaskInlineEdit = async function(id) { + const titleInput = document.getElementById(`task-edit-title-${id}`); + const dateInput = document.getElementById(`task-edit-date-${id}`); + const prioInput = document.getElementById(`task-edit-prio-${id}`); + + if (!titleInput || !titleInput.value.trim()) { + alert('Описание задачи не может быть пустым'); + return; + } + + try { + const res = await fetch(`/api/v1/tasks/${id}`, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify({ + title: titleInput.value.trim(), + due_date: dateInput ? (dateInput.value || null) : null, + priority: prioInput ? prioInput.value : 'MEDIUM' + }) + }); + + if (res.ok) { + window.sendChatAction('покажи задачи'); + } else { + const err = await res.json(); + alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка')); + } + } catch (e) { + console.error('Ошибка сохранения задачи:', e); + alert('Сетевая ошибка при обновлении задачи'); + } +}; +```