diff --git a/core/repositories/scud_repo.py b/core/repositories/scud_repo.py index d6c525e..41650ab 100644 --- a/core/repositories/scud_repo.py +++ b/core/repositories/scud_repo.py @@ -21,34 +21,50 @@ 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 '%23:59:59' OR snapshot_time LIKE '%22:00:00') LIMIT 1", + """ + SELECT 1 FROM scud_logs + WHERE log_date = ? + AND (snapshot_id LIKE 'Y%' OR snapshot_id LIKE '%_FINAL%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00') + LIMIT 1 + """, (date_str,) ) return cursor.fetchone() is not None def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str: - try: - dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date() - date_prefix = dt_snap.strftime("%Y%m%d") - except (ValueError, TypeError): - dt_snap = datetime.now().date() - date_prefix = dt_snap.strftime("%Y%m%d") - + """ + Генерирует понятный и уникальный ID снапшота: + - Дата префикса берется строго из даты самих логов (date_str). + - Для итоговых срезов дня: YYYYYMMDD_FINAL (строго с буквой Y в начале). + - Для дневных срезов на время: YYYYMMDD_HHMM. + """ if date_str: try: - dt_log = datetime.strptime(date_str, "%d.%m.%Y").date() - if dt_log < dt_snap: - is_yesterday = True + dt_log = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").date() + date_prefix = dt_log.strftime("%Y%m%d") except Exception: - pass + dt_log = datetime.now().date() + date_prefix = dt_log.strftime("%Y%m%d") + else: + try: + dt_snap = datetime.strptime(snapshot_time.split()[0], "%Y-%m-%d").date() + date_prefix = dt_snap.strftime("%Y%m%d") + except (ValueError, TypeError, IndexError): + date_prefix = datetime.now().strftime("%Y%m%d") - prefix = "Y" if is_yesterday else "" + time_part = "2359" + try: + t_str = snapshot_time.split()[1] if " " in snapshot_time else snapshot_time + t_parts = t_str.split(":") + time_part = f"{t_parts[0]}{t_parts[1]}" + except Exception: + pass with get_connection() as conn: cursor = conn.cursor() - # 1. Проверяем, существует ли уже срез с точно таким же временем и датой + # Если срез с точно таким же временем и датой уже существует — возвращаем его ID 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", @@ -64,21 +80,32 @@ 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 за текущие календарные сутки + # Финальный суточный ID: строго с префиксом Y + if is_yesterday or time_part in ["2359", "2200"]: + base_final_id = f"Y{date_prefix}_FINAL" + cursor.execute("SELECT 1 FROM scud_logs WHERE snapshot_id = ? LIMIT 1", (base_final_id,)) + if not cursor.fetchone(): + return base_final_id + return base_final_id + + # Дневной срез на определенное время + base_id = f"{date_prefix}_{time_part}" + cursor.execute("SELECT 1 FROM scud_logs WHERE snapshot_id = ? LIMIT 1", (base_id,)) + if not cursor.fetchone(): + return base_id + cursor.execute(""" SELECT DISTINCT snapshot_id FROM scud_logs - WHERE snapshot_id LIKE ? OR snapshot_id LIKE ? - """, (f"{date_prefix}-%", f"Y{date_prefix}-%")) + WHERE snapshot_id LIKE ? + """, (f"{base_id}-%",)) rows = cursor.fetchall() - max_seq = 0 - + max_seq = 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]) @@ -88,15 +115,16 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste continue next_seq = max_seq + 1 - return f"{prefix}{date_prefix}-{next_seq:03d}" + return f"{base_id}-{next_seq:03d}" def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None: if df_scud is None or df_scud.empty: return + now_local_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if not snapshot_time: - snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + snapshot_time = now_local_str snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday) @@ -114,7 +142,8 @@ def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = N 1 if r.get('Пришел', False) else 0, r.get('anomaly_flag', 'NONE'), snapshot_time, - snapshot_id + snapshot_id, + now_local_str # ⭐️ Передаем локальное время машины напрямую ) for _, r in df_scud.iterrows() ] @@ -126,9 +155,9 @@ def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = N INSERT INTO scud_logs ( log_date, fio, fio_clean, department, position, time_in, first_activity, time_out, time_in_building, - is_present, anomaly_flag, snapshot_time, snapshot_id + is_present, anomaly_flag, snapshot_time, snapshot_id, created_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, data_to_insert) conn.commit() @@ -148,25 +177,26 @@ def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> with get_connection() as conn: df = pd.DataFrame() - # 1. Если передан конкретный ID снапшота (например 'Y20260820-004') if snapshot_param: df = pd.read_sql_query( "SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(str(snapshot_param),) ) - # 2. Если ищем за дату (для вчерашнего дня строго ищем Y-снапшот) if df.empty and date_str: cursor = conn.cursor() - # ⭐️ Жесткий приоритет 1: Ищем снапшот с префиксом 'Y' cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY snapshot_time DESC, id DESC LIMIT 1", + """ + SELECT snapshot_id FROM scud_logs + WHERE log_date = ? + AND (snapshot_id LIKE 'Y%' OR snapshot_id LIKE '%_FINAL%') + ORDER BY snapshot_time DESC, id DESC LIMIT 1 + """, (date_str,) ) row = cursor.fetchone() - # Приоритет 2: Если Y нет (например, за сегодня), берем самый свежий по времени if not row: cursor.execute( "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1", @@ -209,7 +239,12 @@ def get_available_snapshots(date_str: str = None): with get_connection() as conn: cursor = conn.cursor() query = """ - SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as cnt + SELECT + snapshot_id, + log_date, + snapshot_time, + COUNT(*) as cnt, + MIN(created_at) as created_at FROM scud_logs WHERE snapshot_time IS NOT NULL """ @@ -239,11 +274,8 @@ def delete_snapshots_by_date(date_str: str) -> int: conn.commit() return cnt + def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int: - """ - Сохраняет ленту сырых физических проходов турникетов в таблицу scud_events_raw. - Перезаписывает сырые события за указанную дату для исключения дубликатов. - """ if df_raw_events is None or df_raw_events.empty: return 0 @@ -257,6 +289,7 @@ def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int: str(r.get('Подразделение', '')), int(r.get('Event', 0)), int(r.get('Mode', 0)), + int(r.get('DoorIndex')) if pd.notna(r.get('DoorIndex')) else None, str(r.get('Direction', 'OTHER')) ) for _, r in df_raw_events.iterrows() @@ -268,9 +301,59 @@ def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int: cursor.executemany(""" INSERT INTO scud_events_raw ( log_date, time_val, hoz_organ, fio, fio_clean, - department, event_code, mode, direction + department, event_code, mode, door_index, direction ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, records) conn.commit() - return len(records) \ No newline at end of file + return len(records) + + +def get_building_presence(date_str: str, only_inside: bool = True) -> list[dict]: + with get_connection() as conn: + cursor = conn.cursor() + query = """ + WITH RankedEvents AS ( + SELECT + hoz_organ, + fio, + department, + time_val, + direction, + ROW_NUMBER() OVER ( + PARTITION BY hoz_organ + ORDER BY time_val DESC, id DESC + ) as rn + FROM scud_events_raw + WHERE log_date = ? + ) + SELECT + hoz_organ, + fio, + department, + time_val, + direction + FROM RankedEvents + WHERE rn = 1 + """ + if only_inside: + query += " AND direction = 'IN'" + + query += " ORDER BY fio ASC;" + + cursor.execute(query, (date_str,)) + rows = cursor.fetchall() + + results = [ + { + "hoz_organ": r[0], + "fio": r[1], + "department": r[2], + "last_event_time": r[3], + "direction": r[4], + "status": "В здании" if r[4] == "IN" else "Вышел" + } + for r in rows + ] + results.sort(key=lambda x: x["fio"].lower()) + return results \ No newline at end of file diff --git a/core/schema.py b/core/schema.py index e48b5fd..a9b0cf5 100644 --- a/core/schema.py +++ b/core/schema.py @@ -50,6 +50,7 @@ def init_all_tables() -> None: department TEXT, event_code INTEGER NOT NULL, mode INTEGER NOT NULL, + door_index INTEGER DEFAULT 1, direction TEXT NOT NULL, -- 'IN' или 'OUT' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/data/static_reason_absence.csv b/data/static_reason_absence.csv new file mode 100644 index 0000000..efe5289 --- /dev/null +++ b/data/static_reason_absence.csv @@ -0,0 +1,6 @@ +reason +отгул +дежурство +обучение +экзамен в Ростехнадзоре +выходной день по ТД diff --git a/data/static_reason_workers.csv b/data/static_reason_workers.csv index 5d7167c..6d7dd3b 100644 --- a/data/static_reason_workers.csv +++ b/data/static_reason_workers.csv @@ -2,7 +2,6 @@ fio,department,reason,date_from,date_to Королёва Наталья Александровна,Все,Удаленная работа,, Николаева Ирина Леонидовна,Все,Удаленная работа,, Познякова Татьяна Сергеевна,Все,Удаленная работа,, -Софьин Никита Сергеевич,Все,Удаленная работа,, Чуб Александр Васильевич,Все,Удаленная работа,, Шуличенко Иван Иванович,Все,Удаленная работа,, Пухаренко Юрий Владимирович,Все,Удаленная работа,, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5a44ad1..9fec38f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,27 @@ Все важные изменения проекта документируются в этом файле. +## [2.5.8] - 2026-09-10 + +### Добавлено (Added) +- **Двухконтурная физическая модель СКУД (Правый PERCo):** + - Поддержка проходов через оба турникета (`DoorIndex IN (1, 2)`) для сотрудников дворовых служб (отделы `ЭТО`, `ЛЦ` и др.). + - Реестр доступа к правому турникету в `exceptions_registry` с категориями `turnstile_fio` и `turnstile_departments`. + - Корректная склейка ФИО (`Name + FirstName + MidName`) в динамическом SQL-фильтре MS SQL. +- **Интерактивное модальное окно исключений:** + - Замена браузерного `prompt()` на модальную форму в едином Tailwind-стиле с автоподбором сотрудников из 1С:ЗУП (`staff-autocomplete`). + - Поле примечания/комментария для фиксации оснований допуска. +- **Точное локальное время создания срезов:** + - Явная фиксация времени хоста при записи срезов в `scud_logs.created_at`, устраняющая 3-часовое смещение UTC внутри контейнеров. +- **Декларативное подтверждение удаления срезов:** + - Добавление параметра `confirmed: boolean` в схему `TOOLS_SCHEMA` инструмента `db_delete_snapshots`, исключающее зацикливание подтверждений в LLM. + +### Изменено (Changed) +- **Упразднение искусственного автозакрытия смен:** + - Полное отключение механизма `calculate_autoclose_time` по согласованию с отделом кадров. При отсутствии физической отметки на турникете статус «Нет выхода» сохраняется без искажения аналитики. +- **Математический расчет временных границ срезов:** + - Перевод формирования `@EndDate` в `services/scud_export.py` на цепочку функций `DATEADD` от `@StartDate`, устраняющий зависимость от регионального формата даты (`DMY` vs `MDY`). + ## [Unreleased] - 2026-09-07 ### Добавлено diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md index af5b912..2f7e40d 100644 --- a/docs/in_progress/ROADMAP.md +++ b/docs/in_progress/ROADMAP.md @@ -1,176 +1,196 @@ # План реализации (ROADMAP) ## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]` -- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов. -- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``). +- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 2]. +- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 2]. --- ## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]` -- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`. -- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты. - - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение. -- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью. -- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`. -- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов. +- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 2]. +- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию[cite: 2]: + - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 2]. + - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 2]. +- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 2]. +- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 2]. +- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 2]. --- ## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]` -- [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 в истории. +- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 2]. +- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 2]. +- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 2]. +- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 2]. +- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 2]. --- ## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]` -- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**. -- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением. -- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`). -- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика. +- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 2]. +- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 2]. +- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 2]. +- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 2]. --- ## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]` -- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`). -- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail). -- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`). -- [x] **Строгий вызов Базы Знаний:** внедрено правило 2.9 в системный промпт для пресечения текстовой имитации вызова `db_get_rules`. +- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 2]. +- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 2]. +- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 2]. +- [x] **Строгий вызов Базы Знаний:** внедрено правило 2.9 в системный промпт для пресечения текстовой имитации вызова `db_get_rules`[cite: 2]. --- ## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]` - [x] **Масштабирование UI задач:** - - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`. - - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все». - - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих). + - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 2]. + - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 2]. + - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 2]. - [x] **Инлайн-редактирование карточки задачи:** - - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности). - - Отображение даты создания задачи. - - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`. + - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 2]. + - Отображение даты создания задачи[cite: 2]. + - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 2]. - [x] **Детерминированный Fast-Path и двухфазное удаление:** - - Мгновенная смена статусов без задержек LLM. - - Карточка подтверждения удаления с автоочисткой контекста. + - Мгновенная смена статусов без задержек LLM[cite: 2]. + - Карточка подтверждения удаления с автоочисткой контекста[cite: 2]. - [x] **Генерация отчетов задач в Markdown:** - - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`. - - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`). + - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 2]. + - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 2]. - [x] **Доменная консолидация задач:** - - Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`. + - Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 2]. --- ## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]` -- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00. -- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат. +- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00 / 23:59:59[cite: 2]. +- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 2]. - [x] **Интерактивный UI с чекбоксами и защитой срезов:** - - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке. - - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора). - - Динамическая кнопка «Удалить выбранные (N)» в подвале карточки. -- [x] **Детерминированный Fast-Path удаления срезов:** - - Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов. - - Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления. + - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 2]. + - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 2]. + - Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 2]. +- [x] **Декларативное управление удалением срезов:** + - Добавлен параметр `confirmed` в схему `TOOLS_SCHEMA` для `db_delete_snapshots`, исключающий зацикливание подтверждений в LLM. + - Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 2]. + - Корректное отображение времени создания срезов в интерфейсе (устранение смещения UTC относительно локального времени сервера). --- ## 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]` - [x] **Выделение общего слоя ядра (`core/`):** - - Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов. - - DDL-схемы таблиц и индексов (`core/schema.py`). - - Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`). - - Фасад обратной совместимости (`core/database.py`). + - Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов[cite: 2]. + - DDL-схемы таблиц и индексов (`core/schema.py`)[cite: 2]. + - Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`)[cite: 2]. + - Фасад обратной совместимости (`core/database.py`)[cite: 2]. - [x] **Декомпозиция предметных доменов (`services/`):** - - Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`). + - Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`)[cite: 2]. - [x] **Рефакторинг ETL-конвейера (`services/scud_etl/`):** - - Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`. + - Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`[cite: 2]. - [x] **Модульный ИИ-оркестратор (`modules/ai_engine/`):** - - Динамический строитель системного контекста (`context_builder.py`). - - Изолированные обработчики инструментов в `handlers/`. + - Динамический строитель системного контекста (`context_builder.py`)[cite: 2]. + - Изолированные обработчики инструментов в `handlers/`[cite: 2]. --- -## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и автозакрытие 8.5ч `[ЗАВЕРШЕНО]` +## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и кадровый аудит `[ЗАВЕРШЕНО]` - [x] **Реестр исключений в SQLite (`exceptions_registry`):** - - Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД. - - Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`. + - Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД[cite: 2]. + - Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`[cite: 2]. - [x] **Агрегация мульти-пропусков физлиц в СКУД:** - - Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие). - - Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`). + - Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие)[cite: 2]. + - Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`)[cite: 2]. - [x] **Умный выбор ставки совместителей 1С:ЗУП:** - - Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД. -- [x] **Автозакрытие смен по Правилу 8.5ч:** - - Для забывших отметиться на выходе офисных сотрудников: автоматический расчет `Вход + 8ч 30мин`, норма 8 часов и отклонение `0:00` в `excel_exporter.py`. + - Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД[cite: 2]. +- [x] **Отказ от искусственного автозакрытия смен:** + - Полное отключение механизма дорисовывания 8.5 часов (`calculate_autoclose_time`) по согласованию с отделом кадров. + - Сохранение честного статуса «Нет выхода» для прозрачности кадрового аудита и выявления нарушений. - [x] **Кэш сопоставлений личностей (`person_identity_mapping`):** - - Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`. - - Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`. + - Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`[cite: 2]. + - Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`[cite: 2]. - [x] **Каскадный fallback кадровых отсутствий:** - - `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`. + - `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`[cite: 2]. --- ## 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`. +- [x] Разделение логики генерации на `svodka_generator.py` и `otchet_generator.py`[cite: 2]. +- [x] Перевод времени суточного среза `Y` на `23:59:59`[cite: 2]. +- [x] Интеллектуальный поиск срезов `services/snapshots/finder.py` (Snap-to-Grid ±20 мин)[cite: 2]. +- [x] Флаг `--time` и интерактивный help в `main_etl.py`[cite: 2]. +- [x] Флаг `--export-only` для почасового крона[cite: 2]. +- [x] Политика ночной ротации промежуточных срезов `services/snapshots/retention.py`[cite: 2]. +- [x] Исправление целочисленного инкремента `snapshot_id`[cite: 2]. --- ## 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-ресурсах. +- [x] **Миграция на `XlsxWriter`:** полный перевод экспорта книг (`scud_export.py`, `excel_exporter.py`) на чистый генератор, устранивший ошибки OpenXML и окна восстановления при открытии[cite: 2]. +- [x] **Интерактивные группировки в Сводке:** восстановление сворачивания категорий по умолчанию (`level=1`, `collapsed=True`) и кнопок управления уровнями `[+]`/`[-]` (`outline_settings(symbols_below=False)`)[cite: 2]. +- [x] **Компактная экранная верстка Детального отчета:** центрирование столбца «Подразделение», расширение столбца «ФИО» (+10%, ширина 33), оптимизация ширины колонок «Первая активность» (11) и «Отклонение от нормы» (11) для отображения без горизонтальной прокрутки[cite: 2]. +- [x] **Защита от блокировок занятых файлов (Fallback Timestamp):** механизм перехвата `FileCreateError`/`OSError` в `safe_close_workbook` с сохранением копии при открытом в Excel файле[cite: 2]. +- [x] **Безопасная синхронизация 1С с сетевой шары:** переход на `shutil.copyfile` в `share_copier.py` для устранения сбоев прав доступа (`Operation not permitted`) на SMB/CIFS-ресурсах[cite: 2]. --- -## 12. UI/UX веб-интерфейса и скроллинг ленты сообщений `[ЗАВЕРШЕНО]` -- [x] **Паттерн Gemini-скролла:** реализация функции `scrollToUserMessageTop` в `core.js`, позиционирующей свежий вопрос пользователя строго по верхней кромке видимой области. -- [x] **Полное скрытие истории:** предыдущие объемные ответы модели гарантированно вытесняются за верхний край экрана. -- [x] **Стабилизация панели ввода:** настройка `padding-bottom: 80vh` для `#chat-messages-container` в `index.html`, обеспечивающая необходимый запас высоты прокрутки без сдвига строки ввода текста. +## 12. UI/UX веб-интерфейса и управление реестрами `[ЗАВЕРШЕНО]` +- [x] **Паттерн Gemini-скролла:** реализация функции `scrollToUserMessageTop` в `core.js`, позиционирующей свежий вопрос пользователя строго по верхней кромке видимой области[cite: 2]. +- [x] **Полное скрытие истории:** предыдущие объемные ответы модели гарантированно вытесняются за верхний край экрана[cite: 2]. +- [x] **Стабилизация панели ввода:** настройка `padding-bottom: 80vh` для `#chat-messages-container` в `index.html`, обеспечивающая необходимый запас высоты прокрутки без сдвига строки ввода текста[cite: 2]. +- [x] **Модальные формы реестров с автокомплитом 1С:** + - Полный отказ от браузерного `prompt()` при работе со списками исключений. + - Единое Tailwind-окно для добавления исключений с живым поиском сотрудников по базе 1С:ЗУП (`staff-autocomplete`) и полем для комментария/основания. --- -## 13. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]` -- [ ] **Двухконтурный вызов ИИ:** перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`. +## 13. Архитектура обработки событий турникетов и двухконтурный учет `[ЗАВЕРШЕНО]` +- [x] **Опора на физический факт прохода (Event 32):** + - Устранение потери событий из-за рассинхронизации меток Event 28 (разрешение) и Event 32 (проход). + - Переход на плоский SQL-запрос выборки первого входа (`Mode = 1`) и последнего выхода (`Mode = 2`). +- [x] **Двухконтурная модель турникетов (Левый/Правый PERCo):** + - Разрешение использования обоих турникетов (`DoorIndex IN (1, 2)`) для сотрудников дворовых служб (отделы `ЭТО`, `ЛЦ` и др.). + - Защита основного пула офисных сотрудников от транзитных отметок на правом турникете (`DoorIndex = 1`). + - Реестр «Пр. турникет» (`turnstile_fio`, `turnstile_departments`) в базе данных и веб-панели. + - Корректное сопоставление составных ФИО (`Name + FirstName + MidName`) в запросе к `pList`. +- [x] **Таблица сырых событий (`scud_events_raw`):** + - Создание DDL-схемы таблицы и логирование всех физических проходов дня параллельно со снапшотами. + +--- + +## 14. Внутридневной контроль и генерация отчетов из веб-интерфейса `[В РАБОТЕ]` +- [ ] **Моментальное создание срезов из UI:** + - [ ] Кнопка **«Создать моментальный срез»** в шапке хаба «Срезы». + - [ ] Вызов `scud_export.py` с фиксацией состояния на текущую минуту и добавлением среза в список без перезагрузки страницы. +- [ ] **Контекстные кнопки генерации в строках срезов:** + - [ ] Размещение кнопок быстрых действий напротив каждой карточки среза в сайдбаре: + - Для промежуточных срезов (`HH:00`): кнопка **«Сводка»** $\rightarrow$ запуск `svodka_generator.py` на момент среза. + - Для итоговых срезов (`_FINAL` / `Y`): кнопка **«Отчет»** $\rightarrow$ запуск `otchet_generator.py` со сверкой 1С:ЗУП за сутки. + - [ ] Фоновая сборка документа с отображением индикатора и автоматической выдачей ссылки на скачивание файла (`/api/v1/files/download/...`). +- [ ] **Выделенный экран оперативного мониторинга («Текущая сводка»):** + - [ ] Отдельная страница/вкладка оперативного контроля присутствия в реальном времени: + - Метрики: *Всего по штату*, *В здании прямо сейчас*, *На удаленке*, *В командировке*, *Отсутствуют без причины*. + - Быстрый поиск и фильтрация по подразделениям. + - Подсветка аномалий внутри дня (вход без выхода более 10 часов, активность без прохода турникета). + +--- + +## 15. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]` +- [ ] **Двухконтурный вызов ИИ:** перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`[cite: 2]. - [ ] **Якорный табельный номер (TabNo Matching):** - - [ ] Извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП. - - [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite. - - [ ] Защита от смены фамилий и опечаток через инвариант табельного номера. + - [ ] Извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП[cite: 2]. + - [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite[cite: 2]. + - [ ] Защита от смены фамилий и опечаток через инвариант табельного номера[cite: 2]. - [ ] **4-уровневая система предохранителей (Guardrails):** - - Уровень 1: Точный матч ФИО (100%). - - Уровень 2: Матч по табельному номеру при расхождении ФИО. - - Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С. - - Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш. + - Уровень 1: Точный матч ФИО (100%)[cite: 2]. + - Уровень 2: Матч по табельному номеру при расхождении ФИО[cite: 2]. + - Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С[cite: 2]. + - Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш[cite: 2]. --- -## 14. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]` +## 16. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]` - [ ] **Docker/gVisor контур:** - - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups). - - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only. + - Создание изолированного контейнера без доступа к внешней сети (`network: none`) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 2]. + - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 2]. - [ ] **Динамические Python/Pandas вычисления:** - - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 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 + - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 2]. + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 2]. \ No newline at end of file diff --git a/etl_code_snapshot.md b/etl_code_snapshot.md index 545176f..7f296f6 100644 --- a/etl_code_snapshot.md +++ b/etl_code_snapshot.md @@ -329,30 +329,6 @@ if __name__ == "__main__": main() ``` -## File: `./run_cron_etl.sh` -```bash -#!/bin/bash -set -e - -# Переход в папку проекта -cd /home/puh/projects/scud_ai - -# Создание папки для логов -mkdir -p /home/puh/projects/scud_ai/logs - -# Фиксация старта -echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log -echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log -echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log - -# Запуск main_etl.py через интерпретатор окружения -/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1 - -# Фиксация окончания -echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log -echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log -``` - ## File: `./scripts/db_cli.py` ```py import os @@ -364,6 +340,7 @@ from datetime import datetime sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from core.repositories.scud_repo import get_building_presence from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR, EXCEPTIONS_PATH, normalize_fio from core.database import ( get_connection, @@ -458,7 +435,7 @@ def print_stats(): with get_connection() as conn: cursor = conn.cursor() tables = [ - 'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', + 'scud_logs', 'scud_events_raw', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks', 'exceptions_registry' ] @@ -807,6 +784,26 @@ def print_exceptions(): print("-" * 80) print("=" * 80 + "\n") +def print_building_presence(date_str: str, all_statuses: bool = False): + """Выводит оперативный список сотрудников, находящихся в здании.""" + records = get_building_presence(date_str, only_inside=not all_statuses) + + title = f"КТО СЕЙЧАС В ЗДАНИИ [{date_str}]" if not all_statuses else f"ОПЕРАТИВНЫЙ СТАТУС СОТРУДНИКОВ [{date_str}]" + print("\n" + "=" * 80) + print(f"🏢 {title} (Всего: {len(records)})") + print("=" * 80) + + if not records: + print(" Нет данных о проходах за указанную дату.") + else: + print(f"{'ФИО':<35} | {'Подразделение':<15} | {'Время':<10} | {'Статус':<8}") + print("-" * 80) + for r in records: + time_short = r['last_event_time'].split()[-1][:8] if ' ' in r['last_event_time'] else r['last_event_time'][:8] + print(f"{r['fio']:<35} | {r['department'][:15]:<15} | {time_short:<10} | {r['status']:<8}") + + print("=" * 80 + "\n") + HELP_TEXT = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) @@ -815,6 +812,7 @@ CLI-утилита инспекции и управления SQLite базой stats -- Общая статистика строк по всем таблицам БД snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату) scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel + in_building [ДД.ММ.ГГГГ] [--all] -- Оперативный статус: кто сейчас в здании (или все статусы с флагом --all) absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С rules -- Посмотреть правила Базы Знаний ИИ из SQLite @@ -835,6 +833,8 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py stats python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг + python scripts/db_cli.py in_building 07.09.2026 + python scripts/db_cli.py in_building 07.09.2026 --all python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py prompts python scripts/db_cli.py tools @@ -866,7 +866,7 @@ def main(): parser.add_argument('command', nargs='?', default=None, choices=[ 'stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', - 'tools', 'context', 'exceptions' + 'tools', 'context', 'in_building', 'exceptions' ], help="Основная команда") parser.add_argument('action', nargs='?', default=None, help="Действие ('del', 'purge', 'add', 'sync') или дата/сессия") parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, имя файла)") @@ -906,6 +906,10 @@ def main(): print_session_states() elif args.command == 'tools': print_tool_actions() + elif args.command in ('in_building', 'presence'): + # Принимаем дату из позиционного параметра action (или param), либо берем текущую + target_date = args.action if args.action else datetime.now().strftime("%d.%m.%Y") + print_building_presence(target_date, all_statuses=args.all) elif args.command == 'context': if args.action in ['purge', 'clear']: is_all = args.all or (args.param == '--all') @@ -962,6 +966,50 @@ if __name__ == "__main__": main() ``` +## File: `./scripts/cron/run_hourly_snapshot.sh` +```bash +#!/bin/bash +set -e + +cd /home/puh/projects/scud_ai +mkdir -p /home/puh/projects/scud_ai/logs + +echo "[CRON HOURLY START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log +/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/services/scud_export.py >> /home/puh/projects/scud_ai/logs/cron_hourly.log 2>&1 +echo "[CRON HOURLY FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log +``` + +## File: `./scripts/cron/run_reports_only.sh` +```bash +#!/bin/bash +set -e + +cd /home/puh/projects/scud_ai +mkdir -p /home/puh/projects/scud_ai/logs + +echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log +/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py --use-existing-snapshot >> /home/puh/projects/scud_ai/logs/cron_reports.log 2>&1 +echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log +``` + +## File: `./scripts/cron/run_cron_etl.sh` +```bash +#!/bin/bash +set -e + +cd /home/puh/projects/scud_ai +mkdir -p /home/puh/projects/scud_ai/logs + +echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log +echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log +echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log + +/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1 + +echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log +echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log +``` + ## File: `./core/connection.py` ```py """ @@ -1074,6 +1122,24 @@ def init_all_tables() -> None: ); """) + # 1.1 Сырые физические события турникетов СКУД (для перекуров и оперативного статуса) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS scud_events_raw ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + log_date TEXT NOT NULL, + time_val TEXT NOT NULL, + hoz_organ INTEGER NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + department TEXT, + event_code INTEGER NOT NULL, + mode INTEGER NOT NULL, + door_index INTEGER DEFAULT 1, + direction TEXT NOT NULL, -- 'IN' или 'OUT' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + # 2. Кадровые реестры 1С cursor.execute(""" CREATE TABLE IF NOT EXISTS zup_staff ( @@ -1208,6 +1274,8 @@ def init_all_tables() -> None: 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_mapping_scud ON person_identity_mapping(scud_fio);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_raw_events_date_hoz ON scud_events_raw(log_date, hoz_organ);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_raw_events_date_time ON scud_events_raw(log_date, time_val);") conn.commit() ``` @@ -1454,6 +1522,96 @@ def delete_snapshots_by_date(date_str: str) -> int: cnt = cursor.rowcount conn.commit() return cnt + +def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int: + """ + Сохраняет ленту сырых физических проходов турникетов в таблицу scud_events_raw. + Перезаписывает сырые события за указанную дату для исключения дубликатов. + """ + if df_raw_events is None or df_raw_events.empty: + return 0 + + records = [ + ( + date_str, + str(r.get('TimeVal', '')), + int(r.get('HozOrgan', 0)), + str(r.get('Сотрудник', '')), + str(r.get('fio_clean', '')), + str(r.get('Подразделение', '')), + int(r.get('Event', 0)), + int(r.get('Mode', 0)), + int(r.get('DoorIndex', 1)) if pd.notna(r.get('DoorIndex')) else 1, + str(r.get('Direction', 'OTHER')) + ) + for _, r in df_raw_events.iterrows() + ] + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_events_raw WHERE log_date = ?", (date_str,)) + cursor.executemany(""" + INSERT INTO scud_events_raw ( + log_date, time_val, hoz_organ, fio, fio_clean, + department, event_code, mode, door_index, direction + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, records) + conn.commit() + return len(records) + +def get_building_presence(date_str: str, only_inside: bool = True) -> list[dict]: + """ + Возвращает оперативный статус сотрудников за дату на основе scud_events_raw. + Если only_inside=True, возвращаются только находящиеся в здании на момент крайнего события. + """ + with get_connection() as conn: + cursor = conn.cursor() + query = """ + WITH RankedEvents AS ( + SELECT + hoz_organ, + fio, + department, + time_val, + direction, + ROW_NUMBER() OVER ( + PARTITION BY hoz_organ + ORDER BY time_val DESC, id DESC + ) as rn + FROM scud_events_raw + WHERE log_date = ? + ) + SELECT + hoz_organ, + fio, + department, + time_val, + direction + FROM RankedEvents + WHERE rn = 1 + """ + if only_inside: + query += " AND direction = 'IN'" + + query += " ORDER BY fio ASC;" + + cursor.execute(query, (date_str,)) + rows = cursor.fetchall() + + results = [ + { + "hoz_organ": r[0], + "fio": r[1], + "department": r[2], + "last_event_time": r[3], + "direction": r[4], + "status": "В здании" if r[4] == "IN" else "Вышел" + } + for r in rows + ] + results.sort(key=lambda x: x["fio"].lower()) + return results ``` ## File: `./core/repositories/zup_repo.py` @@ -1742,9 +1900,33 @@ def load_absent_data(date_str): except Exception as e: print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}") + # Обогащение реестрами "Мест. командир." и "Иное" + try: + from services.manual_absences_repo import get_active_manual_absences_for_date + manual_records = get_active_manual_absences_for_date(date_str) + if manual_records: + existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() + manual_rows = [] + for r in manual_records: + fc = r['fio_clean'] + if fc not in existing_fios: + label = "Мест. командир." if r['absence_type'] == 'LOCAL_TRIP' else "Иное" + manual_rows.append({ + 'fio_clean': fc, + 'Вид_отсутствия': label + }) + existing_fios.add(fc) + if manual_rows: + df_absent = pd.concat([df_absent, pd.DataFrame(manual_rows)], ignore_index=True) + print(f" [✓] Реестры 'Мест. командир.' / 'Иное': добавлено {len(manual_rows)} чел. в отчет за {date_str}") + except Exception as e: + print(f" [⚠️] Ошибка применения manual_absences: {e}") + return df_absent if not df_absent.empty else None + + def load_1c_data_smart(date_str, use_db=False): df_staff = None df_absent = None @@ -1789,6 +1971,7 @@ 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 +from core.repositories.scud_repo import save_raw_events_to_db warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") @@ -1844,43 +2027,65 @@ DECLARE @TargetDate DATE = @InputDate; DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME); DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate)); -WITH DailyLogs AS ( +WITH RawPercoLogs AS ( + -- 1. Выбираем события по левому турникету PERCo (DoorIndex = 1) SELECT log.HozOrgan AS EmployeeID, log.TimeVal, log.Event, log.Mode, CASE - 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' + WHEN log.Mode = 2 THEN 'OUT' + WHEN log.Mode = 1 THEN 'IN' ELSE 'OTHER' - END AS Direction, - ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc + END AS Direction FROM pLogData log WITH (NOLOCK) WHERE log.TimeVal BETWEEN @StartDate AND @EndDate AND log.HozOrgan IS NOT NULL AND log.HozOrgan > 0 - AND log.Event IN (1, 2, 21, 26, 27, 28, 29, 32, 33, 54, 55, 64, 65) + AND log.DoorIndex = 1 + AND log.Event IN (28, 32) + AND log.Mode IN (1, 2) +), +ConfirmedPassages AS ( + -- 2. Фактический проход (событие 32), которому предшествовало + -- разрешение доступа (событие 28) за 1-5 секунд до поворота планки + SELECT + p32.EmployeeID, + p32.TimeVal, + p32.Direction + FROM RawPercoLogs p32 + WHERE p32.Event = 32 + AND EXISTS ( + SELECT 1 + FROM RawPercoLogs p28 + WHERE p28.EmployeeID = p32.EmployeeID + AND p28.Event = 28 + AND p28.Direction = p32.Direction + AND p28.TimeVal <= p32.TimeVal + AND p28.TimeVal >= DATEADD(SECOND, -5, p32.TimeVal) + ) ), Passages AS ( + -- 3. Первый вход и последний выход строго по подтвержденным проворотам планки (32) SELECT EmployeeID, MIN(TimeVal) AS FirstRawEvent, MAX(TimeVal) AS LastRawEvent, MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, - -- Реальный выход: должен быть позже входа минимум на 5 минут (отсекает проезд через шлагбаум/парковку) - MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS RawLastOut - FROM DailyLogs + MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS FinalOut + FROM ConfirmedPassages GROUP BY EmployeeID ), EvaluatedPassages AS ( SELECT p.*, + -- Время выхода проставляется только при окончательном уходе сотрудника из здания: CASE - WHEN p.RawLastOut IS NOT NULL + WHEN p.FinalOut IS NOT NULL AND p.FirstIn IS NOT NULL - AND p.RawLastOut > DATEADD(MINUTE, 5, p.FirstIn) - THEN p.RawLastOut + AND p.FinalOut > DATEADD(MINUTE, 5, p.FirstIn) + THEN p.FinalOut ELSE NULL END AS FilteredLastOut FROM Passages p @@ -1951,6 +2156,42 @@ WHERE ORDER BY p.Name ASC; """ +SQL_RAW_EVENTS_QUERY = r""" +DECLARE @InputDate DATE = '{target_date}'; +DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME); +DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate)); + +SELECT + log.TimeVal, + log.HozOrgan, + LTRIM(RTRIM( + ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') + + CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> '' + THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END + + CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> '' + THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END + )) AS [Сотрудник], + ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение], + log.Event, + log.Mode, + ISNULL(log.DoorIndex, 1) AS DoorIndex, + CASE + WHEN log.Mode = 2 THEN 'OUT' + WHEN log.Mode = 1 THEN 'IN' + WHEN log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT' + WHEN log.Event IN (1, 21, 26, 54, 64) THEN 'IN' + ELSE 'OTHER' + END AS Direction +FROM pLogData log WITH (NOLOCK) +INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID +LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID +WHERE log.TimeVal BETWEEN @StartDate AND @EndDate + AND log.HozOrgan IS NOT NULL + AND log.HozOrgan > 0 + AND log.Event IN (28, 32) + AND ISNULL(p.StatusRecord, 0) = 0 +ORDER BY log.TimeVal ASC; +""" def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"): workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False}) @@ -2077,22 +2318,30 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly) save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday) - log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS") - if save_xlsx: - file_name = f"Сотрудники_{processing_date_str}.xlsx" - file_path = os.path.join(SCUD_DIR, file_name) + raw_sql = SQL_RAW_EVENTS_QUERY.format(target_date=processing_date.strftime("%Y-%m-%d")) + df_raw = pd.read_sql(raw_sql, connection) + if len(df_raw) > 0: + df_raw['fio_clean'] = df_raw['Сотрудник'].apply(clean_scud_fio_light) + inserted_count = save_raw_events_to_db(df_raw, processing_date_str) + log(f"[✓] В scud_events_raw сохранено {inserted_count} сырых событий проходов за {processing_date_str}!", "SUCCESS") - if os.path.exists(file_path): - try: - os.remove(file_path) - except OSError as e: - log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") + log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS") - 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") + if save_xlsx: + file_name = f"Сотрудники_{processing_date_str}.xlsx" + file_path = os.path.join(SCUD_DIR, file_name) + + if os.path.exists(file_path): + try: + os.remove(file_path) + except OSError as e: + log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") + + 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") except Exception as e: log(f"🛑 ОШИБКА выгрузки СКУД за {processing_date_str}: {e}", "ERROR") @@ -2429,7 +2678,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.write(current_row, 1, "", fmt_np_rr) current_row += 1 - # 3. Официальные отсутствия (Сотрудники из исключений при наличии документа 1С попадают сюда) + # 3. Официальные отсутствия reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) @@ -2454,10 +2703,16 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.write(current_row, 1, len(group), fmt_cat_hr) current_row += 1 - for fio in sorted(group['Сотрудник'].dropna().unique()): + is_other_category = (str(cat_name).strip().lower() == "иное") + + for _, row in group.sort_values(by='Сотрудник').iterrows(): + fio = row.get('Сотрудник', '') + # Если категория "Иное" — берем детальную причину из manual_absences / detailed_reason + detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else "" + 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) + ws.write(current_row, 1, detail_val, fmt_cat_rr) current_row += 1 # 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок) @@ -2715,145 +2970,6 @@ def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): safe_close_workbook(wb, output_path, target_dir, filename) ``` -## File: `./services/text_reporter.py` -```py -import json -import difflib -import re -from services.ai_verifier import ask_ollama -from services.knowledge_base import load_knowledge_base - - -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 [] - - 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 [] - - suggestions = [] - for scud_fio in unexp_fios: - f_parts = scud_fio.split() - if not f_parts: - continue - surname = f_parts[0].lower() - - 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 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"): - kb = load_knowledge_base() - custom_rules = kb.get("rules", []) - custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." - - total_staff = len(merged_df) - present_office_cnt = len(merged_df[merged_df['Пришел'] == True]) - - 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]) - - 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 - - # ⭐️ Теневые подсказки ИИ - ai_suggestions = find_ai_identity_suggestions(absent_unexplained, raw_staff_df) - - anomalies_formatted = [] - if anomalies_list: - for idx, a in enumerate(anomalies_list, 1): - anomalies_formatted.append(f"{idx}. {a.get('fio', 'Сотрудник')}: {a.get('description', '')}") - anomalies_text_block = "\n".join(anomalies_formatted) if anomalies_formatted else "Аномалий не обнаружено." - - unexplained_list = [] - if not absent_unexplained.empty: - for idx, r in absent_unexplained.iterrows(): - fio = r.get('Сотрудник', r.get('fio_clean', '')) - dept_scud = str(r.get('department_scud', '')).strip() - dept_1c = str(r.get('Подразделение', '')).strip() - dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c - if not dept: - dept = '—' - pos = r.get('Должность', '—') - unexplained_list.append(f"{fio} — {dept}, {pos}") - - prompt = f""" -Ты — старший аудитор кадровой безопасности и контроллинга СКУД. -Сформируй итоговую сводку кадрового контроля на {date_str}. - -📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ: -{custom_rules_str} - -🚨 ВХОДНЫЕ МЕТРИКИ: -- Всего сотрудников: {total_staff} -- Итого на работе (в офисе): {present_office_cnt} -- В том числе на удаленной работе: {remote_home_cnt} -- Официально отсутствуют: {explained_cnt} -- Неизвестно (истинно неотмеченные): {unexplained_cnt} -- Выявлено аномалий/конфликтов реестров: {anomalies_cnt} - ---- -🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт): -{anomalies_text_block} - ---- -💡 ПРЕДЛОЖЕНИЯ ИИ ПО СОПОСТАВЛЕНИЮ ФИО ({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. В разделе "Подозрения на ошибки сопоставления ФИО" выведи рекомендации по сопоставлению из массива "ПРЕДЛОЖЕНИЯ ИИ". Если массив пуст — напиши "Ошибок сопоставления ФИО не обнаружено." -3. В разделе "Неизвестные случаи" выведи нумерованный список всех {unexplained_cnt} человек. -4. В разделе "Рекомендации" дай 2-3 системные рекомендации. - -СТРОГИЙ ШАБЛОН ОТВЕТА: - -**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}** - -- Всего офисных сотрудников: **{total_staff}** -- Итого на работе (в офисе): **{present_office_cnt}** -- В том числе на удаленной работе: **{remote_home_cnt}** -- Официально отсутствуют: **{explained_cnt}** -- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел. -- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. - -#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): -(Список аномалий или 'Аномалий не обнаружено.') - -#### 💡 Подозрения на ошибки сопоставления ФИО и предложения ИИ: -(Список предложений сопоставления или 'Ошибок сопоставления ФИО не обнаружено.') - -#### Неизвестные случаи: {unexplained_cnt} -(Нумерованный список всех {unexplained_cnt} человек) - -### Точечные рекомендации: -(2-3 системные рекомендации) -""" - - sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке форматированным текстом. Запрещено выводить фигурные скобки JSON." - return ask_ollama(prompt, system_prompt=sys_prompt) -``` - ## File: `./services/exceptions_repo.py` ```py """ @@ -2942,6 +3058,224 @@ def sync_json_to_db(): print(f"[⚠️] Ошибка синхронизации JSON -> DB: {e}") ``` +## File: `./services/manual_absences_repo.py` +```py +""" +=============================================================================== +FILE: services/manual_absences_repo.py +ROLE: Репозиторий ручных реестров ("Мест. командир.", "Иное") и поиск по штату 1С. +=============================================================================== +""" + +import os +import csv +from datetime import datetime +from typing import List, Dict, Any, Optional +from core.connection import get_connection +from config import normalize_fio, DATA_DIR + +REASONS_CSV_PATH = os.path.join(DATA_DIR, "static_reason_absence.csv") + + +def init_manual_absences_table() -> None: + with get_connection() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS manual_absences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + absence_type TEXT NOT NULL, -- 'LOCAL_TRIP' или 'OTHER' + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + department TEXT DEFAULT '', + position TEXT DEFAULT '', + date_start TEXT, -- 'YYYY-MM-DD' + date_end TEXT, -- 'YYYY-MM-DD' + reason TEXT NOT NULL, + comment TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_abs_dates ON manual_absences(date_start, date_end);") + conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_abs_fio ON manual_absences(fio_clean);") + conn.commit() + + +def get_static_reasons() -> List[str]: + """Возвращает список причин из data/static_reason_absence.csv.""" + if not os.path.exists(REASONS_CSV_PATH): + # Если файл еще не создан, создаем базовый набор причин + os.makedirs(os.path.dirname(REASONS_CSV_PATH), exist_ok=True) + default_reasons = ["По семейным обстоятельствам", "Медосмотр", "Сдача крови", "Учебный отпуск", "Административный отпуск"] + with open(REASONS_CSV_PATH, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow(["reason"]) + for r in default_reasons: + writer.writerow([r]) + return default_reasons + + reasons = [] + try: + with open(REASONS_CSV_PATH, "r", encoding="utf-8") as f: + reader = csv.reader(f) + for row in reader: + if row and row[0].strip() and row[0].strip().lower() != "reason": + reasons.append(row[0].strip()) + except Exception: + pass + return reasons + + +def search_staff_suggestions(query: str, limit: int = 15) -> List[Dict[str, str]]: + """Живой поиск сотрудников по zup_staff для автокомплита.""" + q = (query or "").strip() + if not q or len(q) < 2: + return [] + + # Приводим к разным регистрам для гарантированного поиска кириллицы в SQLite + q_lower = q.lower() + q_title = q.capitalize() + + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + + # 1. Находим действительно самый свежий срез штата (по created_at или по структуре даты ГГГГ-ММ-ДД) + cursor.execute(""" + SELECT snapshot_date + FROM zup_staff + ORDER BY + SUBSTR(snapshot_date, 7, 4) DESC, + SUBSTR(snapshot_date, 4, 2) DESC, + SUBSTR(snapshot_date, 1, 2) DESC, + id DESC + LIMIT 1 + """) + row = cursor.fetchone() + latest_date = row[0] if row else None + + if not latest_date: + return [] + + # 2. Поиск с сортировкой: сначала те, у кого фамилия НАЧИНАЕТСЯ с запроса + sql = """ + SELECT DISTINCT fio, fio_clean, department, position + FROM zup_staff + WHERE snapshot_date = ? + AND ( + fio LIKE ? OR fio LIKE ? OR fio_clean LIKE ? OR fio_clean LIKE ? + OR fio LIKE ? OR fio_clean LIKE ? + ) + ORDER BY + CASE + WHEN fio LIKE ? OR fio_clean LIKE ? THEN 0 + ELSE 1 + END, + fio ASC + LIMIT ? + """ + prefix_pattern_title = f"{q_title}%" + prefix_pattern_lower = f"{q_lower}%" + any_pattern_title = f"%{q_title}%" + any_pattern_lower = f"%{q_lower}%" + + cursor.execute(sql, ( + latest_date, + prefix_pattern_title, prefix_pattern_lower, prefix_pattern_title, prefix_pattern_lower, + any_pattern_title, any_pattern_lower, + prefix_pattern_title, prefix_pattern_title, + limit + )) + rows = cursor.fetchall() + + return [ + { + "fio": r["fio"], + "fio_clean": r["fio_clean"], + "department": r["department"] or "—", + "position": r["position"] or "—" + } + for r in rows + ] + + +def add_manual_absence( + absence_type: str, + fio: str, + reason: str, + department: str = "", + position: str = "", + date_start: Optional[str] = None, + date_end: Optional[str] = None, + comment: str = "" +) -> int: + init_manual_absences_table() + clean_fio = normalize_fio(fio) + if not clean_fio: + return 0 + + today_str = datetime.now().strftime("%Y-%m-%d") + d_start = date_start.strip() if date_start and date_start.strip() else today_str + d_end = date_end.strip() if date_end and date_end.strip() else today_str + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO manual_absences ( + absence_type, fio, fio_clean, department, position, + date_start, date_end, reason, comment + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (absence_type.upper(), fio.strip(), clean_fio, department.strip(), position.strip(), d_start, d_end, reason.strip(), comment.strip())) + conn.commit() + return cursor.lastrowid + + +def delete_manual_absence(item_id: int) -> bool: + init_manual_absences_table() + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM manual_absences WHERE id = ?", (item_id,)) + conn.commit() + return cursor.rowcount > 0 + + +def get_manual_absences_list(absence_type: Optional[str] = None) -> List[Dict[str, Any]]: + init_manual_absences_table() + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + if absence_type: + cursor.execute(""" + SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason, comment, created_at + FROM manual_absences + WHERE absence_type = ? + ORDER BY id DESC + """, (absence_type.upper(),)) + else: + cursor.execute(""" + SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason, comment, created_at + FROM manual_absences + ORDER BY id DESC + """) + return [dict(r) for r in cursor.fetchall()] + + +def get_active_manual_absences_for_date(date_str: str) -> List[Dict[str, Any]]: + """Выбирает записи, активные на дату отчета (формат даты ДД.ММ.ГГГГ).""" + init_manual_absences_table() + try: + dt_target = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").strftime("%Y-%m-%d") + except Exception: + dt_target = datetime.now().strftime("%Y-%m-%d") + + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason + FROM manual_absences + WHERE (date_start IS NULL OR date_start <= ?) + AND (date_end IS NULL OR date_end >= ?) + """, (dt_target, dt_target)) + return [dict(r) for r in cursor.fetchall()] +``` + ## File: `./services/zup_extractor.py` ```py import os @@ -3703,6 +4037,19 @@ def merge_scud_and_1c( if reason and reason.lower() != "nan": absences_map[fio] = reason + manual_reasons_map = {} + try: + from services.manual_absences_repo import get_active_manual_absences_for_date + # date_clean берется из даты контекста либо из текущих суток + target_date_val = df_res.get('Дата', pd.Series()).iloc[0] if 'Дата' in df_res.columns and not df_res.empty else None + if target_date_val: + m_records = get_active_manual_absences_for_date(str(target_date_val)) + for mr in m_records: + manual_reasons_map[mr['fio_clean']] = mr['reason'] + except Exception: + pass + + df_res["detailed_reason"] = df_res["fio_clean"].map(manual_reasons_map).fillna("") df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) df_res["Вид_отсутствия"] = df_res["причина отсутствия"] @@ -3777,6 +4124,153 @@ def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: } ``` +## File: `./services/scud_etl/svodka_generator.py` +```py +""" +=============================================================================== +FILE: services/scud_etl/svodka_generator.py +ROLE: Генератор Ежедневной Сводки (оперативный контроль, текущий срез). +=============================================================================== +""" + +import os +import logging +from typing import Dict, Any, Optional +import pandas as pd + +from config import DATE_TODAY +from core.database import load_scud_from_db_by_snapshot +from services.scud_etl.pipeline import load_1c_files_for_date +from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics +from services.scud_etl.anomaly_detector import detect_registry_anomalies +from services.snapshots.finder import find_or_create_snapshot_for_time +from services.excel_exporter import generate_summary_excel, get_dated_reports_dir, format_date_ru + +logger = logging.getLogger("SVODKA_GENERATOR") + + +def generate_svodka_service( + target_date: Optional[str] = None, + target_time: Optional[str] = None, + snapshot_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Формирует оперативную сводку на указанную дату / время: + - target_date: дата сводки (по умолчанию сегодня). + - target_time: время среза (например '14:30'). + - snapshot_id: точный ID среза. + """ + date_clean = str(target_date or DATE_TODAY).replace('_', '.') + applied_note = "" + + # Если передано время, но не указан конкретный snapshot_id — ищем ближайший или запрашиваем экспорт + if target_time and not snapshot_id: + found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=True) + snapshot_id = found_id + applied_note = note + if note: + logger.info(note) + + df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id) + if df_scud is None or df_scud.empty: + return { + "status": "error", + "message": f"Срез СКУД за {date_clean} ({applied_note or snapshot_id or 'последний доступный'}) не найден в базе." + } + + df_staff, df_abs = load_1c_files_for_date(date_clean) + + df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs) + metrics = calculate_summary_metrics(df_merged) + anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud) + + # Добавляем суффикс времени в имя файла, если сводка строилась на точный срез + time_suffix = f" на {target_time.replace(':', '-')}" if target_time else "" + filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx" + generate_summary_excel(df_merged, date_str=date_clean, filename=filename) + + target_dir = get_dated_reports_dir(date_clean) + full_filepath = os.path.join(target_dir, filename) + + return { + "status": "success", + "report_type": "SVODKA", + "date": date_clean, + "target_time": target_time, + "snapshot_id": snapshot_id or "AUTO_LATEST", + "filename": filename, + "filepath": full_filepath, + "download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}", + "metrics": metrics, + "anomalies_count": len(anomalies), + "note": applied_note, + "message": f"Ежедневная сводка на {date_clean} {target_time or ''} успешно сформирована." + } +``` + +## File: `./services/scud_etl/otchet_generator.py` +```py +""" +=============================================================================== +FILE: services/scud_etl/otchet_generator.py +ROLE: Генератор Детального Отчета за прошлые смены (строго по итоговому Y-снапшоту). +=============================================================================== +""" + +import os +import logging +from typing import Dict, Any, Optional +import pandas as pd + +from config import DATE_YESTERDAY +from core.database import load_scud_from_db_by_snapshot +from services.scud_etl.pipeline import load_1c_files_for_date +from services.scud_etl.merger import merge_scud_and_1c +from services.excel_exporter import generate_detailed_excel, get_dated_reports_dir, format_date_ru + +logger = logging.getLogger("OTCHET_GENERATOR") + + +def generate_otchet_service( + target_date: Optional[str] = None, + snapshot_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Формирует детальный отчет за прошедшую смену: + - target_date: дата отчета (по умолчанию вчерашний рабочий день). + - snapshot_id: опциональный ID (по умолчанию выбирается итоговый вечерний срез Y). + """ + date_clean = str(target_date or DATE_YESTERDAY).replace('_', '.') + + df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id) + if df_scud is None or df_scud.empty: + return { + "status": "error", + "message": f"Итоговый срез СКУД (Y) за {date_clean} не найден в базе данных." + } + + df_staff, df_abs = load_1c_files_for_date(date_clean) + df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs) + + filename = f"{format_date_ru(date_clean)} отчет.xlsx" + generate_detailed_excel(df_merged, date_str=date_clean, filename=filename) + + target_dir = get_dated_reports_dir(date_clean) + full_filepath = os.path.join(target_dir, filename) + + return { + "status": "success", + "report_type": "OTCHET", + "date": date_clean, + "snapshot_id": snapshot_id or "AUTO_Y_FINAL", + "filename": filename, + "filepath": full_filepath, + "download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}", + "total_rows": len(df_merged), + "message": f"Детальный отчет за {date_clean} успешно сформирован." + } +``` + ## File: `./services/scud_etl/anomaly_detector.py` ```py """ diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index 97b038a..bc63692 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -106,21 +106,31 @@ def process_chat_message( 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" - ) + # ⭐️ Защита от залипания: если вопрос бытовой или отвлеченный, выходим из жесткого режима инспекции + msg_l = user_message.lower().strip() + scud_terms = ["срез", "скуд", "вход", "выход", "здани", "присутств", "отсутств", "кто в", "кто сейчас", "1с", "зуп", "турникет", "карточк", "инспекци"] + if not any(t in msg_l for t in scud_terms) and len(msg_l.split()) <= 12: + db_clear_session_state(session_id) + current_state_type = None + active_state_context = "" + else: + 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}. При любых вопросах о сотрудниках, фильтрации по входам, выходам, времени или отделам:\n" + f"1. ТЫ ОБЯЗАН ответить обычным текстом, проанализировав список ниже.\n" + f"2. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструменты (tools), такие как db_get_snapshots!\n" + f"Список сотрудников в активном срезе:\n{dump_str}\n" + ) # ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам system_prompt_content = ( diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py index d90093c..20f4287 100644 --- a/modules/web_api/llm/schemas.py +++ b/modules/web_api/llm/schemas.py @@ -146,7 +146,11 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_get_snapshots", - "description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.", + "description": ( + "Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n" + "ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n" + "КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!" + ), "parameters": { "type": "object", "properties": { @@ -162,17 +166,21 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_delete_snapshots", - "description": "Удаление снапшотов СКУД по идентификатору или дате.", + "description": "Удаление дневных снапшотов СКУД по идентификатору или дате.", "parameters": { "type": "object", "properties": { "snapshot_id": { "type": "string", - "description": "Идентификатор конкретного снапшота" + "description": "Идентификатор или список идентификаторов через запятую" }, "day_str": { "type": "string", "description": "Дата всех снапшотов за день" + }, + "confirmed": { + "type": "boolean", + "description": "Флаг окончательного подтверждения удаления пользователем" } } } diff --git a/modules/web_api/main.py b/modules/web_api/main.py index a0bbe46..5bf55f1 100644 --- a/modules/web_api/main.py +++ b/modules/web_api/main.py @@ -26,6 +26,7 @@ from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from fastapi.exceptions import RequestValidationError +from routers.manual_absences import router as manual_absences_router from routers.auth import router as auth_router from routers.admin import router as admin_router @@ -69,6 +70,7 @@ app.include_router(exceptions_router) app.include_router(snapshots_router) app.include_router(remote_workers_router) app.include_router(context_router) +app.include_router(manual_absences_router) # ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") diff --git a/modules/web_api/routers/manual_absences.py b/modules/web_api/routers/manual_absences.py new file mode 100644 index 0000000..ad61b05 --- /dev/null +++ b/modules/web_api/routers/manual_absences.py @@ -0,0 +1,71 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/manual_absences.py +ROLE: REST API эндпоинты для реестров "Мест. командир.", "Иное" и автокомплита. +=============================================================================== +""" + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel +from typing import Optional, List, Dict, Any + +from services.manual_absences_repo import ( + search_staff_suggestions, + get_static_reasons, + add_manual_absence, + delete_manual_absence, + get_manual_absences_list +) + +router = APIRouter(prefix="/api/v1/manual-absences", tags=["Manual Absences"]) + + +class AddAbsenceRequest(BaseModel): + absence_type: str # 'LOCAL_TRIP' или 'OTHER' + fio: str + reason: Optional[str] = "" + department: Optional[str] = "" + position: Optional[str] = "" + date_start: Optional[str] = None + date_end: Optional[str] = None + comment: Optional[str] = "" + + +@router.get("/staff-autocomplete") +def api_staff_autocomplete(q: str = Query(..., min_length=2)): + return search_staff_suggestions(q) + + +@router.get("/reasons") +def api_get_reasons(): + return {"reasons": get_static_reasons()} + + +@router.get("/") +def api_list_manual_absences(type: Optional[str] = None): + return {"items": get_manual_absences_list(type)} + + +@router.post("/") +def api_add_manual_absence(req: AddAbsenceRequest): + reason = req.reason or ("Местная командировка" if req.absence_type == "LOCAL_TRIP" else "Иное") + res_id = add_manual_absence( + absence_type=req.absence_type, + fio=req.fio, + reason=reason, + department=req.department, + position=req.position, + date_start=req.date_start, + date_end=req.date_end, + comment=req.comment + ) + if not res_id: + raise HTTPException(status_code=400, detail="Не удалось добавить запись") + return {"status": "success", "id": res_id} + + +@router.delete("/{item_id}") +def api_delete_manual_absence(item_id: int): + if not delete_manual_absence(item_id): + raise HTTPException(status_code=404, detail="Запись не найдена") + return {"status": "success"} \ No newline at end of file diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html index 8ef94a9..705e1c6 100644 --- a/modules/web_api/static/index.html +++ b/modules/web_api/static/index.html @@ -8,7 +8,6 @@ + + + +
+ + + + + +
+
+
+
+ +
+
+
+

SCUD Orion AI Assistant

+ Online +
+

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

+
+
+
+ + +
+
+
+ +
+
+
ИИ-ассистент SCUD Orion AI
+
+ Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты. +
+
+
+
+ + + + + +
+
+ + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## File: `./modules/web_api/static/js/sidebar.js` ```js /** * =============================================================================== - * FILE: modules/web_api/static/js/auth.js - * ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков. + * FILE: modules/web_api/static/js/sidebar.js + * ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2), + * подробным описанием управления контекстом и интеграцией чата. * =============================================================================== */ -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"; +window.escapeHtml = function(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; -const AuthManager = { - getToken() { - return localStorage.getItem(AUTH_STORAGE_KEY) || ""; +window.SidebarManager = { + currentHub: 'TASKS', + currentSubTab: { + 'REGISTRIES': 'REMOTE' }, - getUserId() { - const uid = localStorage.getItem(USER_ID_STORAGE_KEY); - return uid ? parseInt(uid, 10) : 1; + 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' } + ], + + // ⭐️ Сетка реестров 2x2 + subTabs: { + 'REGISTRIES': [ + { id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' }, + { id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' }, + { id: 'LOCAL_TRIP', label: 'Мест. командир.', icon: 'fa-location-dot' }, + { id: 'OTHER', label: 'Иное', icon: 'fa-clipboard-list' } + ] }, - getUsername() { - return localStorage.getItem(USERNAME_STORAGE_KEY) || ""; + init() { + this.renderHeader(); + this.renderContent(); }, - getFullName() { - return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь"; + setHub(hubId) { + this.currentHub = hubId; + this.renderHeader(); + this.renderContent(); }, - isAdmin() { - return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true"; + setSubTab(subTabId) { + this.currentSubTab[this.currentHub] = subTabId; + this.renderHeader(); + this.renderContent(); }, - isAuthenticated() { - return Boolean(this.getToken()); - }, + renderHeader() { + const headerContainer = document.getElementById("sidebar-dynamic-header"); + if (!headerContainer) return; - 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); - }, + // 1. Основные 5 Хабов + const hubsHtml = ` +
+ ${this.hubs.map(h => { + const isActive = this.currentHub === h.id; + return ` + + `; + }).join('')} +
+ `; - getAuthHeaders() { - const token = this.getToken(); - const headers = { "Content-Type": "application/json" }; - if (token) { - headers["Authorization"] = `Bearer ${token}`; + // 2. Подвкладки реестров (Сетка 2x2 в строгом соответствии с макетом) + 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('')} +
+ `; } - return headers; + + headerContainer.innerHTML = hubsHtml + subTabsHtml; }, - logout() { - console.log("[Auth] Полный выход из системы..."); - localStorage.clear(); - sessionStorage.clear(); + renderContent() { + const contentContainer = document.getElementById("sidebar-dynamic-content"); + if (!contentContainer) return; - 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=/`; - }); + contentContainer.scrollTop = 0; - window.location.href = "/"; + 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 if (subTab === 'EXCEPTIONS') { + this.renderExceptionsView(container); + } else { + this.renderManualAbsencesView(container, subTab); + } + }, + + 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("Ошибка сети"); + } + }, + + // ⭐️ Новые подвкладки: Местная командировка и Иное + async renderManualAbsencesView(container, absenceType) { + const typeLabel = absenceType === 'LOCAL_TRIP' ? 'местных командировок' : 'иных отсутствий'; + container.innerHTML = `
Загрузка ${typeLabel}...
`; + try { + const res = await fetch(`/api/v1/manual-absences/?type=${absenceType}`); + const data = res.ok ? await res.json() : { items: [] }; + const items = data.items || []; + + const listHtml = items.map(it => { + const dFrom = it.date_start ? it.date_start : 'сегодня'; + const dTo = it.date_end ? it.date_end : 'сегодня'; + const periodLabel = (dFrom === dTo) ? `на ${dFrom}` : `${dFrom} — ${dTo}`; + const badgeText = absenceType === 'LOCAL_TRIP' ? 'Местная командировка' : escapeHtml(it.reason); + + return ` +
+
+
${escapeHtml(it.fio)}
+
${escapeHtml(it.department || 'Все')} · ${badgeText}
+
+ + ${escapeHtml(periodLabel)} +
+
+
+ +
+
+ `; + }).join(''); + + container.innerHTML = ` +
+
+ В реестре: ${items.length} чел. + +
+
+ ${items.length > 0 ? listHtml : '
Список пуст
'} +
+
+ `; + } catch (e) { + container.innerHTML = `
Ошибка загрузки реестра
`; + } + }, + + async deleteManualAbsenceRecord(id) { + if (!confirm("Удалить эту запись из реестра?")) return; + try { + const res = await fetch(`/api/v1/manual-absences/${id}`, { method: "DELETE" }); + 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); + } } }; -window.AuthManager = AuthManager; -window.logout = () => AuthManager.logout(); +document.addEventListener("DOMContentLoaded", () => { + if (window.AuthManager && AuthManager.isAuthenticated()) { + SidebarManager.init(); + } +}); ``` ## File: `./modules/web_api/static/js/tasks.js` @@ -2247,20 +3702,255 @@ window.loadTasks = loadTasks; window.setTaskFilter = setTaskFilter; ``` +## File: `./modules/web_api/static/js/manual_absences.js` +```js +/** + * =============================================================================== + * FILE: modules/web_api/static/js/manual_absences.js + * ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП + * и мгновенная синхронизация с боковой панелью SidebarManager. + * =============================================================================== + */ + +let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER' +let reasonsCache = []; + +async function loadAbsenceReasons() { + try { + const res = await fetch('/api/v1/manual-absences/reasons'); + if (res.ok) { + const data = await res.json(); + reasonsCache = data.reasons || []; + } + } catch (e) { + console.error('Ошибка загрузки причин:', e); + } +} + +function openManualAbsenceModal(type) { + activeAbsenceType = type; + const isTrip = type === 'LOCAL_TRIP'; + const titleEl = document.getElementById('manual-absence-modal-title'); + const reasonBlock = document.getElementById('manual-absence-reason-block'); + const reasonSelect = document.getElementById('manual-absence-reason-select'); + + if (titleEl) { + titleEl.innerHTML = isTrip + ? 'Местная командировка' + : 'Иные причины отсутствия'; + } + + if (reasonBlock && reasonSelect) { + if (isTrip) { + reasonBlock.classList.add('hidden'); + } else { + reasonBlock.classList.remove('hidden'); + reasonSelect.innerHTML = reasonsCache.map(r => ``).join(''); + } + } + + // Сброс полей ввода + const fioInput = document.getElementById('manual-absence-fio-input'); + const deptInput = document.getElementById('manual-absence-dept'); + const posInput = document.getElementById('manual-absence-pos'); + const startDateInput = document.getElementById('manual-absence-start-date'); + const endDateInput = document.getElementById('manual-absence-end-date'); + const suggestionsBox = document.getElementById('manual-absence-suggestions'); + + if (fioInput) fioInput.value = ''; + if (deptInput) deptInput.value = ''; + if (posInput) posInput.value = ''; + if (startDateInput) startDateInput.value = ''; + if (suggestionsBox) { + suggestionsBox.classList.add('hidden'); + suggestionsBox.innerHTML = ''; + } + + // Окончание по умолчанию — сегодняшний день + if (endDateInput) { + const today = new Date().toISOString().split('T')[0]; + endDateInput.value = today; + } + + loadManualAbsencesTable(); + const modal = document.getElementById('manual-absence-modal'); + if (modal) modal.classList.remove('hidden'); +} + +function closeManualAbsenceModal() { + const modal = document.getElementById('manual-absence-modal'); + if (modal) modal.classList.add('hidden'); +} + +// Живой автокомплит ФИО из базы zup_staff +let searchTimeout = null; +function setupStaffAutocomplete(inputEl, suggestionsBoxId) { + const box = document.getElementById(suggestionsBoxId); + if (!inputEl || !box) return; + + inputEl.addEventListener('input', function() { + const val = this.value.trim(); + clearTimeout(searchTimeout); + if (val.length < 2) { + box.classList.add('hidden'); + box.innerHTML = ''; + return; + } + + searchTimeout = setTimeout(async () => { + try { + const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`); + if (!res.ok) return; + const items = await res.json(); + if (items.length === 0) { + box.classList.add('hidden'); + return; + } + + box.innerHTML = items.map(it => ` +
+ ${escapeHtml(it.fio)} + ${escapeHtml(it.department)} · ${escapeHtml(it.position)} +
+ `).join(''); + box.classList.remove('hidden'); + } catch (e) { + console.error(e); + } + }, 200); + }); +} + +function selectStaffSuggestion(fio, dept, pos) { + const fioInput = document.getElementById('manual-absence-fio-input'); + const deptInput = document.getElementById('manual-absence-dept'); + const posInput = document.getElementById('manual-absence-pos'); + const box = document.getElementById('manual-absence-suggestions'); + + if (fioInput) fioInput.value = fio; + if (deptInput) deptInput.value = dept; + if (posInput) posInput.value = pos; + if (box) box.classList.add('hidden'); +} + +async function submitManualAbsence() { + const fioInput = document.getElementById('manual-absence-fio-input'); + const fio = fioInput ? fioInput.value.trim() : ''; + if (!fio) { + alert('Укажите ФИО сотрудника'); + return; + } + + const deptVal = document.getElementById('manual-absence-dept')?.value.trim() || ''; + const posVal = document.getElementById('manual-absence-pos')?.value.trim() || ''; + const startDateVal = document.getElementById('manual-absence-start-date')?.value || null; + const endDateVal = document.getElementById('manual-absence-end-date')?.value || null; + const reasonSelect = document.getElementById('manual-absence-reason-select'); + + const payload = { + absence_type: activeAbsenceType, + fio: fio, + department: deptVal, + position: posVal, + date_start: startDateVal, + date_end: endDateVal, + reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное') + }; + + try { + const res = await fetch('/api/v1/manual-absences/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (res.ok) { + if (fioInput) fioInput.value = ''; + loadManualAbsencesTable(); + // Обновляем список карточек в боковой панели и закрываем окно + if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') { + SidebarManager.renderContent(); + } + closeManualAbsenceModal(); + } else { + alert('Ошибка добавления записи'); + } + } catch (e) { + alert('Сетевая ошибка при добавлении'); + } +} + +async function loadManualAbsencesTable() { + const tableContainer = document.getElementById('manual-absences-table-body'); + if (!tableContainer) return; + + try { + const res = await fetch(`/api/v1/manual-absences/?type=${activeAbsenceType}`); + if (!res.ok) return; + const data = await res.json(); + const items = data.items || []; + + if (items.length === 0) { + tableContainer.innerHTML = 'Нет активных записей'; + return; + } + + tableContainer.innerHTML = items.map(it => ` + + ${escapeHtml(it.fio)} + ${escapeHtml(it.department || '—')} + ${escapeHtml(it.reason)} + ${it.date_start || '—'} / ${it.date_end || '—'} + + + + + `).join(''); + } catch (e) { + console.error(e); + } +} + +async function deleteManualAbsenceRecord(id) { + if (!confirm('Удалить эту запись?')) return; + try { + const res = await fetch(`/api/v1/manual-absences/${id}`, { method: 'DELETE' }); + if (res.ok) { + loadManualAbsencesTable(); + if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') { + SidebarManager.renderContent(); + } + } + } catch (e) { + alert('Ошибка при удалении'); + } +} + +document.addEventListener('DOMContentLoaded', () => { + loadAbsenceReasons(); + setupStaffAutocomplete( + document.getElementById('manual-absence-fio-input'), + 'manual-absence-suggestions' + ); +}); +``` + ## 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. + * ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, авто-высота инпута (24px), + * надежный расчет скролла вопроса к верху окна, крупный шрифт text-sm. * =============================================================================== */ let currentAttachedFile = null; -const STORAGE_KEY = "scud_chat_input_history"; -let chatInputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); +const CHAT_INPUT_STORAGE_KEY = "scud_chat_input_history"; +let chatInputHistory = JSON.parse(localStorage.getItem(CHAT_INPUT_STORAGE_KEY) || "[]"); let chatHistoryIndex = -1; let temporaryCurrentInput = ""; @@ -2272,63 +3962,34 @@ function saveCommandToHistory(commandText) { chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd); chatInputHistory.push(cleanCmd); if (chatInputHistory.length > 50) chatInputHistory.shift(); - localStorage.setItem(STORAGE_KEY, JSON.stringify(chatInputHistory)); + localStorage.setItem(CHAT_INPUT_STORAGE_KEY, JSON.stringify(chatInputHistory)); chatHistoryIndex = -1; } -// Поиск скролл-контейнера -function getActualScrollContainer() { - const listEl = document.getElementById("chat-messages-container"); - if (!listEl) return window; +// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: выравнивание вопроса к верхней границе +function scrollToUserMessageTop() { + const container = document.getElementById("chat-messages-container"); + if (!container) return; - 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; -} + const userBubbles = container.querySelectorAll(".user-chat-bubble"); + const targetEl = userBubbles[userBubbles.length - 1]; + if (!targetEl) return; -// Прецизионный скролл сообщения оператора к верхнему краю экрана -function scrollToUserMessageTop(element) { - if (!element) return; + requestAnimationFrame(() => { + const targetScroll = targetEl.offsetTop - container.offsetTop - 12; - 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; - } + container.scrollTo({ + top: Math.max(0, targetScroll), + behavior: 'smooth' + }); + }); } function updateInputHeightAndFade(textarea) { if (!textarea) return; if (!textarea.value || textarea.value.trim() === '') { - textarea.style.height = '22px'; + textarea.style.height = '24px'; textarea.style.overflowY = 'hidden'; textarea.style.maskImage = 'none'; textarea.style.webkitMaskImage = 'none'; @@ -2336,8 +3997,8 @@ function updateInputHeightAndFade(textarea) { } textarea.style.height = 'auto'; - const minHeight = 22; - const maxHeight = 110; + const minHeight = 24; + const maxHeight = 120; const currentScrollHeight = textarea.scrollHeight; if (currentScrollHeight <= minHeight + 2) { @@ -2374,7 +4035,7 @@ function appendUserMessage(text, filename = null) { } const msgHtml = ` -
+
${fileBadge}
${escapeHtml(text)}
@@ -2385,9 +4046,6 @@ function appendUserMessage(text, filename = null) {
`; container.insertAdjacentHTML("beforeend", msgHtml); - - const userEl = document.getElementById(msgId); - scrollToUserMessageTop(userEl); } function appendAssistantLoading() { @@ -2646,12 +4304,13 @@ window.sendMessage = async function() { saveCommandToHistory(messageText); input.value = ""; - input.style.height = '22px'; + 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}` : ''), @@ -2661,8 +4320,12 @@ window.sendMessage = async function() { appendUserMessage("Сформировать предпросмотр изменений"); } + // 2. Вставка лоадера const loadingId = appendAssistantLoading(); + // 3. Вызов точного скролла + scrollToUserMessageTop(); + try { let res; if (fileToSend) { @@ -2755,8 +4418,8 @@ document.addEventListener("DOMContentLoaded", () => { const overlay = document.getElementById("global-drag-overlay"); if (input) { - input.style.lineHeight = '22px'; - input.style.height = '22px'; + input.style.lineHeight = '24px'; + input.style.height = '24px'; input.addEventListener("keydown", (e) => { if (e.key === "Enter") { @@ -2819,7 +4482,6 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // ⭐️ Глобальный Drag-and-Drop на всё окно браузера let dragCounter = 0; window.addEventListener('dragenter', (e) => { @@ -3308,3 +4970,122 @@ window.saveTaskInlineEdit = async function(id) { } }; ``` + +## 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/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(); + } +}); +```