feat(reports): stabilize on-demand generation, live presence and 1C fallback

This commit is contained in:
2026-09-25 13:00:11 +03:00
parent 2f8cc1bffd
commit 90656944c5
42 changed files with 13410 additions and 6778 deletions
+2
View File
@@ -32,3 +32,5 @@ data/1c/*
*.db-wal
data/scud_orion_ai.db
data/uploads/
*snapshot.md
*.patch
Binary file not shown.
+18
View File
@@ -2,6 +2,24 @@
Все важные изменения проекта документируются в этом файле.
## [3.4.0] — 2026-09-25
### ✨ Добавлено (Added)
- **Оперативная On-Demand генерация отчетов из сайдбара:**
- Мгновенная сборка книги «Ежедневная сводка» по последнему зафиксированному в SQLite срезу СКУД (без ожидания внешнего опроса Орион).
- Строгое разграничение временных рамок отчетов: «Сводка» формируется на текущую смену, а «Детальный» и «Упрощенный» отчеты — строго за прошедшую смену (ВЧЕРА / прошлая пятница) по итоговому срезу `Y`.
- **Фоновая полудневная синхронизация 1С (cron):**
- Интеграция вызова `copy_1c_files_from_share()` и опроса кадровых отсутствий из базы ЗУП (`ACCOUNT-01`) в цикл получасовых срезов.
### 🛡️ Исправлено (Fixed)
- **Ошибка 404 при скачивании отчетов (`download_report_direct`):**
- Добавлен рекурсивный поиск файлов в подпапках `output/reports/{ГОД}/{МЕСЯЦ}/` и декодирование процент-кодированных кириллических имен из URL.
- **Ошибки 500 и 0 по штату 1С при создании сводки:**
- Реализован каскадный fallback: если файл штата на текущий день еще не выложен на шару, данные штата берутся из последнего сохраненного среза `zup_staff` в SQLite (устранено ложное попадание всех сотрудников в «Нет в ЗУП»).
- Исправлены `NameError` импортов (`load_best_snapshot_for_date`, `load_1c_data_smart`).
- **Сбои инспекции срезов (`OperationalError: no such column: hoz_organ`):**
- Переход на динамический `SELECT *` в роутере инспекции срезов и расчет реального времени в здании до момента фиксации снапшота.
## [3.3.0] — 2026-09-24
### ✨ Добавлено (Added)
+63 -49
View File
@@ -1,13 +1,13 @@
# План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 2].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 2].
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов.
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`).
---
## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]`
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 2].
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`.
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию[cite: 2]:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 2].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 2].
@@ -125,7 +125,7 @@
## 11. Стабилизация генератора отчетов и верстки `[ЗАВЕРШЕНО]`
- [x] **Миграция на `XlsxWriter`:** полный перевод экспорта книг (`scud_export.py`, `excel_exporter.py`) на чистый генератор, устранивший ошибки OpenXML и окна восстановления при открытии[cite: 2].
- [x] **Интерактивные группировки в Сводке:** восстановление сворачивания категорий по умолчанию (`level=1`, `collapsed=True`) и кнопок управления уровнями `[+]`/`[-]` (`outline_settings(symbols_below=False)`)[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].
@@ -156,48 +156,7 @@
---
## 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С:ЗУП[cite: 2].
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite[cite: 2].
- [ ] Защита от смены фамилий и опечаток через инвариант табельного номера[cite: 2].
- [ ] **4-уровневая система предохранителей (Guardrails):**
- Уровень 1: Точный матч ФИО (100%)[cite: 2].
- Уровень 2: Матч по табельному номеру при расхождении ФИО[cite: 2].
- Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С[cite: 2].
- Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш[cite: 2].
---
## 16. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
- [ ] **Docker/gVisor контур:**
- Создание изолированного контейнера без доступа к внешней сети (`network: none`) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 2].
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 2].
- [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 2].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 2].
---
## 17. Миграция инфраструктуры, отказоустойчивость отчетов и Упрощенный отчет `[ЗАВЕРШЕНО]`
## 14. Миграция инфраструктуры, отказоустойчивость отчетов и Упрощенный отчет `[ЗАВЕРШЕНО]`
- [x] **Миграция на новый сервер (Debian 12):**
- Актуализация путей виртуального окружения в cron-скриптах (`scripts/cron/*.sh`).
- Восстановление прямого подключения к базе 1С:ЗУП (MS SQL Server `ACCOUNT-01`).
@@ -209,8 +168,63 @@
- Устранение предупреждений `UserWarning: Boolean Series key will be reindexed` при вычислении метрик штата.
- [x] **Стабилизация генерации Excel-книг (`otchet_builder.py`):**
- Включение защитной опции `nan_inf_to_errors: True` в `xlsxwriter.Workbook`.
- Полная санитарная очистка ячеек от значений `NaN` / `None` / `pd.NA` перед записью, устранившая критический сбой `TypeError: NAN/INF not supported in write_number()`.
- Санитизация ячеек от значений `NaN` / `None` / `pd.NA` перед записью, устранившая критический сбой `TypeError: NAN/INF not supported in write_number()`.
- [x] **Генератор «Упрощенного отчета» (`services/reports/simplified_builder.py`):**
- Создание нового генератора по эталонному формату: заголовок `Упрощенный отчет: c ДД.ММ.ГГГГ по ДД.ММ.ГГГГ`, сокращение ФИО до инициалов (`Фамилия И.О.`), отображение времени с текстовыми заглушками (`Нет входа (0:00)`, `Нет выхода (23:59)`).
- Создание генератора по эталонному шаблону: заголовок `Упрощенный отчет: c ДД.ММ.ГГГГ по ДД.ММ.ГГГГ`, сокращение ФИО до инициалов (`Фамилия И.О.`), отображение времени с текстовыми заглушками (`Нет входа (0:00)`, `Нет выхода (23:59)`).
- Сортировка по подразделениям и алфавиту сотрудников.
- Интеграция этапа сборки упрощенного отчета за вчера в ETL-конвейер (`main_etl.py`).
- Интеграция этапа сборки упрощенного отчета за вчера в ETL-конвейер (`main_etl.py`).
- [x] **Компактный динамический сборщик слепков (`make_etl_snapshot.py`):**
- Автоматический обход дерева (auto-discovery) с отсечением дублирующих архивных манифестов, временных файлов и пустых `__init__.py`.
---
## 15. ПРИОРИТЕТ 1: Внутридневной контроль, экран «Кто в здании» и On-Demand генерация `[В РАБОТЕ]`
- [x] **Оперативный контроль «Кто в здании прямо сейчас» (Live Presence):**
- [x] REST API эндпоинт `GET /api/v1/presence/live` на базе таблицы `scud_events_raw` и оконной функции последнего события прохода.
- [x] Выделенная модальная панель в веб-интерфейсе:
- Метрики реального времени: *Штат 1С*, *В здании*, *Вышли*, *На удаленке*, *В командировке/отпуске*, *Не пришли*, *Исключения*.
- Быстрый поиск сотрудника и фильтрация по подразделениям.
- Учет специфики служебных входов/выходов во двор (флигель/двор).
- [x] **On-Demand генерация отчетов из UI и сайдбара:**
- [x] Кнопка **«Сводка»** в сайдбаре: моментальный сбор книги по последнему готовому срезу SQLite без блокирующих опросов MS SQL.
- [x] Кнопки **«Детальн.»** и **«Упрощ.»**: строгое формирование отчетов за **ВЧЕРА** (с учетом выходных) по финальному суточному срезу `Y` (23:59:59).
- [x] Каскадный fallback кадровых файлов: при отсутствии свежего штата за текущее утро автоматическое использование актуального среза из `zup_staff` в SQLite.
- [x] **Атомарная публикация и отдача файлов (Report Delivery Engine):**
- [x] Устранение 404 ошибок: рекурсивный поиск файлов в иерархии `output/reports/{ГОД}/{МЕСЯЦ}/` и декодирование URL-имён.
- [x] Синхронизация при отдаче: автоматическая отдача прямой ссылки на скачивание в браузер.
- [ ] **Реестр «Графики работы» (Custom Work Schedules):**
- [ ] Таблица в SQLite `work_schedules_registry` (`fio_clean`, `department`, `schedule_type`, `norm_hours`, `time_start_window`, `time_end_window`, `valid_from`, `valid_to`).
- [ ] Интеграция индивидуальной нормы часов (4, 7, 11, 24 ч) в калькулятор выработки `calculators.py` вместо константы 8 ч.
- [ ] Вкладка управления графиками в сайдбаре «Реестры» с автоподбором сотрудников из 1С.
---
## 16. ПРИОРИТЕТ 2: Кадровый арбитраж до отчетов и интеграция с MS Exchange `[В ПЛАНАХ]`
- [ ] **Почтовый агент MS Exchange (Email-Driven Controller & Human-in-the-Loop):**
- [ ] Фоновый сервис `services/mail/exchange_bot.py` для опроса служебного ящика по IMAP/SMTP (`imaplib` / `email`) или EWS (`exchangelib`).
- [ ] Белый список доверенных отправителей (адреса сотрудников ОК, СБ и руководства).
- [ ] **Генерация отчетов по письму:** распознавание запросов в теме/теле писем (*«Сводка на 14:00»*, *«Отчет за 22.09.2026»*) и отправка файла в ответном письме.
- [ ] **Разрешение кадровых коллизий через почту:** обработка ответов СБ на утреннюю рассылку аномалий (подтверждение связок в `person_identity_mapping`) и регистрация командировок/служебок в `manual_absences`.
- [ ] Двухфазный Guardrail: запрос подтверждения у кадровика по почте при низкой уверенности в сопоставлении тёзок (<90%).
- [ ] **Интеллектуальный кадровый арбитраж ДО генерации отчетов:**
- [ ] Перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`.
- [ ] **Якорный табельный номер (TabNo Matching):** извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП, защита от смены фамилий и опечаток.
- [ ] 4-уровневая система предохранителей (Точный матч $\rightarrow$ Табельный номер $\rightarrow$ Кэш связок $\rightarrow$ ИИ-арбитраж).
---
## 17. ПРИОРИТЕТ 3: Векторная база знаний (RAG) и внешний контекст `[В ПЛАНАХ]`
- [ ] **Векторный поиск по регламентам компании (ChromaDB / RAG Engine):**
- [ ] Встраиваемая векторная база для семантического поиска по ПВТР, положениям о пропускном режиме и должностным инструкциям.
- [ ] Мгновенная выдача ссылок на пункты локальных нормативных актов при консультации оператора в чате.
- [ ] Семантическая классификация нестандартных формулировок причин отсутствий из писем и заявлений.
- [ ] **Внешний контекстный инструмент (Web Search Agent):**
- [ ] Нативный инструмент `web_search` для обращения к производственному календарю РФ (переносы праздников, предпраздничные сокращенные смены по ст. 95 ТК РФ).
- [ ] Справка по статьям Трудового кодекса РФ (сверхурочные, выходные дни).
---
## 18. Исследовательский трек: Изолированная песочница кода `[ОТЛОЖЕНО]`
- [ ] **Docker/gVisor контур:**
- [ ] Изолированный контейнер без доступа к внешней сети (`network: none`) с жесткими cgroups-лимитами и монтированием данных в режиме Read-Only.
- [ ] Генерация и выполнение Python/Pandas скриптов для построения сложных графиков и нестандартной статистики на лету.
+5087 -2689
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -20,6 +20,7 @@ from services.scud_etl.svodka_generator import generate_svodka_service
from services.scud_etl.otchet_generator import generate_otchet_service
from services.text_reporter import generate_markdown_report
from services.snapshots.retention import cleanup_old_intermediate_snapshots
from services.snapshots.retention import purge_day_intermediate_snapshots
from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share
@@ -164,6 +165,12 @@ def main():
if res_svodka.get("note"):
print(f" ℹ️ {res_svodka.get('note')}")
# Если запуск ночной (после 22:00) или закрывающий смену — зачищаем дневные срезы
if now.hour >= 22 and not args.skip_export:
purged = purge_day_intermediate_snapshots(today_str)
if purged > 0:
print(f"[🧹] Ночная очистка: удалено {purged} строк дневных срезов за {today_str}. Итоговый Y-срез сохранен.")
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
+4 -1
View File
@@ -151,7 +151,10 @@ def process_chat_message(
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n"
f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! "
f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n"
f"4. ПРАВИЛА И РЕГЛАМЕНТЫ: При любых вопросах о правилах компании или арбитраже ТЫ ОБЯЗАН СРАЗУ вызвать db_get_rules.\n"
f"4. ПРАВИЛА КОМПАНИИ: Инструмент db_get_rules вызывай СТРОГО при вопросах о внутренних регламентах, "
"политиках или локальных правилах НАШЕЙ компании (например, 'покажи правила компании', 'какие у нас регламенты'). "
"При вопросах по Трудовому кодексу РФ (ТК РФ), законам РФ или общим юридическим нормам — отвечай подробно "
"из своих профессиональных знаний, не вызывая db_get_rules!"
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
+1 -1
View File
@@ -40,7 +40,7 @@ def init_prompt_nodes_table():
# Раздел 1
(1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"),
(1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."),
(1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."),
(1, 2, "Консультация по внутренним регламентам компании (через db_get_rules). Вопросы по ТК РФ и законам поясняй напрямую."),
(1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."),
(1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."),
(1, 5, "Поддержка диалога с операторами и администраторами системы."),
+4
View File
@@ -37,6 +37,8 @@ from routers.exceptions import router as exceptions_router
from routers.snapshots import router as snapshots_router
from routers.remote_workers import router as remote_workers_router
from routers.context import router as context_router
from routers.presence import router as presence_router
from routers.reports import router as reports_router
# ANCHOR[APP_CONFIG]
logging.basicConfig(
@@ -71,6 +73,8 @@ app.include_router(snapshots_router)
app.include_router(remote_workers_router)
app.include_router(context_router)
app.include_router(manual_absences_router)
app.include_router(presence_router)
app.include_router(reports_router)
# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
+3
View File
@@ -12,11 +12,13 @@ class ExceptionItem(BaseModel):
comment: Optional[str] = ""
@router.get("")
@router.get("/")
def api_get_exceptions():
return get_all_exceptions_from_db()
@router.post("")
@router.post("/")
def api_add_exception(item: ExceptionItem):
if not add_exception_to_db(item.category, item.value, item.comment):
@@ -24,6 +26,7 @@ def api_add_exception(item: ExceptionItem):
return {"status": "success", "data": item}
@router.delete("")
@router.delete("/")
def api_delete_exception(category: str, value: str):
if not remove_exception_from_db(category, value):
+53 -11
View File
@@ -1,8 +1,7 @@
"""
===============================================================================
FILE: modules/web_api/routers/files.py
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
через изолированные UUID-директории инструментов.
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен.
===============================================================================
"""
@@ -10,6 +9,7 @@ import os
import time
import shutil
import urllib.parse
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
@@ -17,13 +17,17 @@ router = APIRouter(prefix="/api/v1/files", tags=["Files"])
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
REPORTS_DIR = os.path.join(BASE_ROOT, "output", "reports")
TEMP_REPORTS_DIR = "/tmp/scud_reports"
SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
os.makedirs(REPORTS_DIR, exist_ok=True)
os.makedirs(TEMP_REPORTS_DIR, exist_ok=True)
SESSION_TTL_HOURS = 24
def purge_old_tool_sessions(tool_dir_path: str):
"""Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
if not os.path.exists(tool_dir_path):
return
now = time.time()
@@ -40,9 +44,6 @@ def purge_old_tool_sessions(tool_dir_path: str):
@router.get("/download/{tool_name}/{session_uuid}/{filename}")
async def download_file(tool_name: str, session_uuid: str, filename: str):
"""
Безопасная отдача файла с каноническим именем из изолированной директории.
"""
safe_tool = os.path.basename(tool_name)
safe_uuid = os.path.basename(session_uuid)
safe_filename = os.path.basename(filename)
@@ -52,16 +53,14 @@ async def download_file(tool_name: str, session_uuid: str, filename: str):
if not os.path.exists(file_path) or not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
# Определение MIME-типа
media_type = "application/octet-stream"
if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
if safe_filename.endswith((".md", ".txt")):
media_type = "text/markdown; charset=utf-8"
elif safe_filename.endswith(".xlsx"):
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
elif safe_filename.endswith(".pdf"):
media_type = "application/pdf"
# Корректная кодировка для кириллических имен файлов
encoded_filename = urllib.parse.quote(safe_filename)
return FileResponse(
@@ -70,4 +69,47 @@ async def download_file(tool_name: str, session_uuid: str, filename: str):
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
)
def _find_report_recursively(target_filename: str) -> Optional[str]:
"""Рекурсивный поиск файла отчета по имени в /tmp и во всех подпапках output/reports/."""
# 1. Проверяем /tmp/scud_reports
tmp_path = os.path.join(TEMP_REPORTS_DIR, target_filename)
if os.path.exists(tmp_path) and os.path.isfile(tmp_path):
return tmp_path
# 2. Проверяем прямой путь в output/reports
direct_path = os.path.join(REPORTS_DIR, target_filename)
if os.path.exists(direct_path) and os.path.isfile(direct_path):
return direct_path
# 3. Рекурсивный поиск по подкаталогам (год/месяц)
for root, _, files in os.walk(REPORTS_DIR):
if target_filename in files:
return os.path.join(root, target_filename)
return None
@router.get("/download/reports/{filename:path}")
async def download_report_direct(filename: str):
"""
Прямое скачивание отчетов:
- Декодирует UTF-8 URL (%20 -> пробел).
- Ищет файл в output/reports/{YEAR}/{MONTH} и во временном буфере /tmp/scud_reports.
"""
decoded_name = urllib.parse.unquote(filename).strip()
safe_filename = os.path.basename(decoded_name)
target_path = _find_report_recursively(safe_filename)
if not target_path or not os.path.exists(target_path):
raise HTTPException(status_code=404, detail="Отчет не найден")
encoded_filename = urllib.parse.quote(safe_filename)
return FileResponse(
path=target_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
)
@@ -68,4 +68,21 @@ def api_add_manual_absence(req: AddAbsenceRequest):
def api_delete_manual_absence(item_id: int):
if not delete_manual_absence(item_id):
raise HTTPException(status_code=404, detail="Запись не найдена")
return {"status": "success"}
class UpdateAbsenceDatesRequest(BaseModel):
id: int
date_start: Optional[str] = None
date_end: Optional[str] = None
@router.put("/{item_id}")
def api_update_manual_absence(item_id: int, req: UpdateAbsenceDatesRequest):
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE manual_absences
SET date_start = ?, date_end = ?
WHERE id = ?
""", (req.date_start, req.date_end, item_id))
conn.commit()
return {"status": "success"}
+26
View File
@@ -0,0 +1,26 @@
"""
===============================================================================
FILE: modules/web_api/routers/presence.py
ROLE: REST API оперативного статуса присутствия сотрудников в здании.
===============================================================================
"""
from fastapi import APIRouter, Query
from typing import Optional, Dict, Any
from services.presence_service import get_live_presence
router = APIRouter(prefix="/api/v1/presence", tags=["Presence"])
@router.get("/live")
def api_get_live_presence(
date_str: Optional[str] = Query(None, description="Дата в формате ДД.ММ.ГГГГ"),
force_refresh: bool = Query(False, description="Принудительный опрос MS SQL Орион")
):
"""
Возвращает оперативный статус сотрудников («Кто в здании»).
По умолчанию возвращает срез моментально из локальной базы SQLite.
При force_refresh=true выполняет опрос турникетов в MS SQL Орион.
"""
return get_live_presence(date_str=date_str, force_refresh=force_refresh)
+128
View File
@@ -0,0 +1,128 @@
"""
===============================================================================
FILE: modules/web_api/routers/reports.py
ROLE: REST API On-Demand генерации отчетов:
- Сводка: за СЕГОДНЯ (оперативный контроль, текущий срез).
- Детальный и Упрощенный отчет: строго за ВЧЕРА по финальному срезу Y.
===============================================================================
"""
import os
import logging
from datetime import datetime, timedelta
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException
from config import DATE_TODAY, DATE_YESTERDAY
from services.scud_etl.svodka_generator import generate_svodka_service
from services.scud_etl.otchet_generator import generate_otchet_service
from services.reports.simplified_builder import generate_simplified_excel
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c
logger = logging.getLogger("REPORTS_API")
router = APIRouter(prefix="/api/v1/reports", tags=["Reports"])
def get_previous_workday(target_date_str: str) -> str:
"""Вычисляет дату предыдущей рабочей смены (в понедельник возвращает пятницу)."""
clean_date = target_date_str.replace('_', '.')
try:
dt = datetime.strptime(clean_date, "%d.%m.%Y")
days_back = 3 if dt.weekday() == 0 else 1
return (dt - timedelta(days=days_back)).strftime("%d.%m.%Y")
except Exception:
return DATE_YESTERDAY
class GenerateReportRequest(BaseModel):
date: Optional[str] = None # ДД.ММ.ГГГГ
time: Optional[str] = None # ЧЧ:ММ
report_type: str # 'SVODKA', 'DETAILED', 'SIMPLIFIED', 'ALL'
@router.post("/generate")
def api_generate_report(req: GenerateReportRequest):
"""
Генерирует выбранный отчет:
- SVODKA: на текущую дату (date).
- DETAILED / SIMPLIFIED: строго за вчерашний рабочий день по финальному срезу Y.
"""
target_date = (req.date or DATE_TODAY).replace('_', '.')
yesterday_date = get_previous_workday(target_date)
r_type = req.report_type.upper()
results = []
# 1. Ежедневная сводка (за СЕГОДНЯ)
if r_type in ["SVODKA", "ALL"]:
res_svodka = generate_svodka_service(
target_date=target_date,
target_time=req.time
)
if res_svodka.get("status") == "success":
results.append({
"type": "Сводка",
"filename": res_svodka.get("filename"),
"download_url": res_svodka.get("download_url"),
"status": "success"
})
else:
results.append({
"type": "Сводка",
"error": res_svodka.get("message"),
"status": "error"
})
# 2. Детальный отчет (строго за ВЧЕРА по финальному срезу Y)
if r_type in ["DETAILED", "ALL"]:
res_det = generate_otchet_service(target_date=yesterday_date)
if res_det.get("status") == "success":
results.append({
"type": "Детальный отчет",
"filename": res_det.get("filename"),
"download_url": res_det.get("download_url"),
"status": "success"
})
else:
results.append({
"type": "Детальный отчет",
"error": res_det.get("message"),
"status": "error"
})
# 3. Упрощенный отчет (строго за ВЧЕРА по финальному срезу Y)
if r_type in ["SIMPLIFIED", "ALL"]:
try:
df_scud_y = load_best_snapshot_for_date(yesterday_date, prefer_final_y=True)
if df_scud_y is not None and not df_scud_y.empty:
df_staff_y, df_abs_y = load_1c_files_for_date(yesterday_date)
df_merged_y = merge_scud_and_1c(df_scud_y, df_staff_y, df_abs_y)
out_path = generate_simplified_excel(df_merged_y, date_str=yesterday_date)
filename = os.path.basename(out_path)
results.append({
"type": "Упрощенный отчет",
"filename": filename,
"download_url": f"/api/v1/files/download/reports/{filename}",
"status": "success"
})
else:
results.append({
"type": "Упрощенный отчет",
"error": f"Финальный срез СКУД (Y) за {yesterday_date} не найден.",
"status": "error"
})
except Exception as e:
logger.exception(f"Ошибка формирования упрощенного отчета: {e}")
results.append({
"type": "Упрощенный отчет",
"error": str(e),
"status": "error"
})
return {
"date": target_date,
"yesterday_date": yesterday_date,
"requested_type": r_type,
"reports": results
}
+372 -25
View File
@@ -1,53 +1,400 @@
"""
===============================================================================
FILE: modules/web_api/routers/snapshots.py
ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД.
ROLE: Роутер срезов СКУД:
- Список срезов за период дат (/api/v1/snapshots)
- Инспекция среза (/api/v1/snapshots/{snapshot_id}/details и /inspect)
- Экспорт среза в Excel (.xlsx) и CSV (UTF-8 с BOM)
- Ручное создание среза (/create)
- Удаление срезов (пакетное через snapshot_ids)
===============================================================================
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import os
import io
import urllib.parse
import logging
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from pathlib import Path
from routers.auth import get_current_user
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from services.scud_export import run_export
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse, FileResponse
from pydantic import BaseModel
import pandas as pd
from core.connection import get_connection
import config
logger = logging.getLogger("SNAPSHOTS_ROUTER")
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
BASE_DIR = getattr(config, "BASE_DIR", Path(__file__).resolve().parent.parent.parent.parent)
SNAPSHOTS_DIR = getattr(config, "SNAPSHOTS_DIR", os.path.join(str(BASE_DIR), "exports", "snapshots"))
DATE_TODAY = getattr(config, "DATE_TODAY", datetime.now().strftime("%d.%m.%Y"))
class CreateSnapshotRequest(BaseModel):
date_str: Optional[str] = None
time_str: Optional[str] = None
class DeleteSnapshotsRequest(BaseModel):
snapshot_ids: List[str]
@router.get("")
def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)):
return get_snapshots_registry(date_str=date_str)
# =============================================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ РАСЧЕТА СТАТУСА И ВРЕМЕНИ
# =============================================================================
def parse_time_str(t_str: str) -> Optional[datetime]:
"""Парсит строку времени HH:MM[:SS] в datetime объект."""
if not t_str or str(t_str).strip() in ("Нет входа", "Нет выхода", "—", "-", "None", "nan"):
return None
for fmt in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(str(t_str).strip(), fmt)
except ValueError:
pass
return None
def calculate_row_presence(d: dict, snapshot_time_str: str) -> dict:
"""
Определяет реальный статус сотрудника и время нахождения в здании.
"""
fio = d.get("fio") or d.get("Сотрудник") or "—"
dept = d.get("department") or d.get("Подразделение") or "—"
t_in = d.get("time_in") or d.get("Начало_дня") or d.get("first_in") or "Нет входа"
first_act = d.get("first_activity") or d.get("Первая_активность") or "—"
t_out = d.get("time_out") or d.get("Конец_дня") or d.get("last_out") or "Нет выхода"
db_in_bld = d.get("in_building") or d.get("Находился_в_здании")
db_status = d.get("status") or d.get("Статус") or d.get("Пришел")
has_in = t_in not in ("Нет входа", "—", "-", "", None, "None")
has_act = first_act not in ("—", "-", "", None, "None")
has_out = t_out not in ("Нет выхода", "—", "-", "", None, "None")
# 1. Если нет отметок прохода — сотрудник отсутствовал
if not has_in and not has_act and not has_out:
final_status = "Отсутствовал (Нет событий)"
final_in_bld = "00:00"
else:
# Сотрудник присутствовал
final_status = "Присутствовал"
# Расчет времени нахождения в здании
dt_in = parse_time_str(t_in) or parse_time_str(first_act)
dt_out = parse_time_str(t_out)
if db_in_bld and db_in_bld not in ("00:00", "—", "", "None"):
final_in_bld = db_in_bld
elif dt_in:
if dt_out and dt_out >= dt_in:
diff = dt_out - dt_in
else:
# Если выхода еще нет — считаем до момента фиксации среза
dt_snap = parse_time_str(snapshot_time_str) or datetime.now()
if dt_snap >= dt_in:
diff = dt_snap - dt_in
else:
diff = timedelta(0)
total_minutes = int(diff.total_seconds() // 60)
hh = total_minutes // 60
mm = total_minutes % 60
final_in_bld = f"{hh:02d}:{mm:02d}"
else:
final_in_bld = "00:00"
# Сохраняем специальные статусы, если они зафиксированы в БД
if db_status and "Отсутств" in str(db_status):
final_status = db_status
return {
"fio": fio,
"department": dept,
"time_in": t_in,
"first_activity": first_act,
"time_out": t_out,
"in_building": final_in_bld,
"status": final_status
}
# =============================================================================
# 1. СПИСОК СРЕЗОВ (С ПОДДЕРЖКОЙ ДИАПАЗОНА ДАТ)
# =============================================================================
@router.get("", include_in_schema=False)
@router.get("/")
def list_snapshots(
date: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None
):
"""
Возвращает список срезов за диапазон дат со всеми полями для интерфейса.
"""
d_from = (date_from or date or DATE_TODAY).replace('_', '.')
d_to = (date_to or date or DATE_TODAY).replace('_', '.')
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_id, COUNT(*) as cnt, MAX(created_at) as created_at, log_date
FROM scud_logs
WHERE (
substr(log_date, 7, 4) || '-' || substr(log_date, 4, 2) || '-' || substr(log_date, 1, 2)
BETWEEN
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
AND
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
)
AND snapshot_id IS NOT NULL AND snapshot_id != ''
GROUP BY snapshot_id
ORDER BY snapshot_id DESC
""", (d_from, d_from, d_from, d_to, d_to, d_to))
rows = cursor.fetchall()
items = []
for r in rows:
raw_id = r["snapshot_id"]
clean_id = str(raw_id).lstrip("#").strip()
total_cnt = r["cnt"]
time_str = "—"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
time_str = f"{parts[1][:2]}:{parts[1][2:4]}"
is_final = "FINAL" in clean_id.upper()
items.append({
"id": clean_id,
"snapshot_id": clean_id,
"label": clean_id,
"snapshot_time": time_str,
"time": time_str,
"record_count": total_cnt,
"count": total_cnt,
"records_count": total_cnt,
"is_final": is_final,
"date": r["log_date"],
"created_at": r["created_at"] or r["log_date"]
})
return {
"date_from": d_from,
"date_to": d_to,
"total_snapshots": len(items),
"snapshots": items
}
# =============================================================================
# 2. ИНСПЕКЦИЯ СРЕЗА (ДЛЯ МОДАЛЬНОГО ОКНА)
# Поддерживает оба пути: /details и /inspect
# =============================================================================
@router.get("/{snapshot_id}/details")
@router.get("/{snapshot_id}/inspect")
def inspect_snapshot(snapshot_id: str):
clean_id = snapshot_id.lstrip("#").strip()
target_date = ""
snapshot_time = "23:59:59"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts[0]) == 8 and parts[0].isdigit():
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
rows = []
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
# Безопасная выборка всех полей строки среза
cursor.execute("""
SELECT * FROM scud_logs
WHERE snapshot_id IN (?, ?, ?, ?)
ORDER BY id ASC
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
rows = cursor.fetchall()
# Резервный сбор на лету из scud_events_raw, если среза нет в scud_logs
if not rows and target_date:
cursor.execute("""
SELECT
fio,
department,
MIN(CASE WHEN direction = 'IN' THEN time_val END) as time_in,
NULL as first_activity,
MAX(CASE WHEN direction = 'OUT' THEN time_val END) as time_out,
'00:00' as in_building,
'Присутствовал' as status
FROM scud_events_raw
WHERE log_date = ?
GROUP BY fio_clean
ORDER BY fio ASC
""", (target_date,))
rows = cursor.fetchall()
if not rows:
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
records = []
for r in rows:
calc = calculate_row_presence(dict(r), snapshot_time)
records.append({
"hoz_organ": dict(r).get("hoz_organ") or dict(r).get("tab_num") or "",
"fio": calc["fio"],
"Сотрудник": calc["fio"],
"department": calc["department"],
"Подразделение": calc["department"],
"time_in": calc["time_in"],
"Вход": calc["time_in"],
"first_activity": calc["first_activity"],
"time_out": calc["time_out"],
"Выход": calc["time_out"],
"in_building": calc["in_building"],
"Находился_в_здании": calc["in_building"],
"status": calc["status"],
"Пришел": calc["status"]
})
return {
"status": "ok",
"snapshot_id": clean_id,
"date": target_date or DATE_TODAY,
"total_records": len(records),
"count": len(records),
"records": records,
"data": records
}
# =============================================================================
# 3. ЭКСПОРТ ДАННЫХ ИНСПЕКЦИИ СРЕЗА (EXCEL / CSV)
# =============================================================================
@router.get("/{snapshot_id}/export")
def api_export_snapshot(snapshot_id: str, format: str = Query("xlsx")):
clean_id = snapshot_id.lstrip("#").strip()
target_date = ""
snapshot_time = "23:59:59"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts[0]) == 8 and parts[0].isdigit():
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
rows = []
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM scud_logs
WHERE snapshot_id IN (?, ?, ?, ?)
ORDER BY id ASC
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
rows = cursor.fetchall()
if not rows:
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
export_list = []
for r in rows:
calc = calculate_row_presence(dict(r), snapshot_time)
export_list.append({
"Сотрудник": calc["fio"],
"Подразделение": calc["department"],
"Вход": calc["time_in"],
"Первая активность": calc["first_activity"],
"Выход": calc["time_out"],
"В здании": calc["in_building"],
"Статус": calc["status"]
})
out_df = pd.DataFrame(export_list)
filename_base = f"Инспекция_{clean_id}"
if format.lower() == "csv":
csv_bytes = out_df.to_csv(index=False, sep=";", encoding="utf-8-sig").encode("utf-8-sig")
filename = f"{filename_base}.csv"
encoded = urllib.parse.quote(filename)
return StreamingResponse(
io.BytesIO(csv_bytes),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
)
else:
output = io.BytesIO()
with pd.ExcelWriter(output, engine="openpyxl") as writer:
out_df.to_excel(writer, index=False, sheet_name="Срез")
output.seek(0)
filename = f"{filename_base}.xlsx"
encoded = urllib.parse.quote(filename)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
)
# =============================================================================
# 4. РУЧНОЕ СОЗДАНИЕ И ПАКЕТНОЕ УДАЛЕНИЕ СРЕЗОВ
# =============================================================================
@router.post("/create")
def api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)):
"""Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite."""
def create_snapshot(req: CreateSnapshotRequest):
from services.scud_export import run_export
target_date = (req.date_str or DATE_TODAY).replace('_', '.')
try:
success = run_export(input_date=req.date_str, save_xlsx=True, debug=False)
if not success:
raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион")
fresh_data = get_snapshots_registry(date_str=req.date_str)
return {"status": "success", "message": "Срез успешно создан", "data": fresh_data}
run_export(input_date=target_date, save_xlsx=True, debug=False)
return {"status": "ok", "message": f"Срез за {target_date} успешно создан"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}")
logger.error(f"Ошибка создания среза: {e}")
raise HTTPException(status_code=500, detail=f"Не удалось создать срез: {e}")
@router.delete("")
def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)):
safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")]
if not safe_ids:
raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления")
@router.delete("", include_in_schema=False)
@router.delete("/")
def delete_snapshots(req: DeleteSnapshotsRequest):
if not req.snapshot_ids:
return {"status": "ok", "deleted": 0}
with get_connection() as conn:
cursor = conn.cursor()
for sid in req.snapshot_ids:
clean_id = sid.lstrip("#").strip()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id IN (?, ?)", (clean_id, f"#{clean_id}"))
conn.commit()
return {"status": "ok", "deleted": len(req.snapshot_ids)}
# =============================================================================
# 5. СКАЧИВАНИЕ ФИЗИЧЕСКИХ ФАЙЛОВ .XLSX (FALLBACK)
# =============================================================================
@router.get("/{filename}")
def download_snapshot_file(filename: str):
safe_filename = os.path.basename(filename)
file_path = os.path.join(SNAPSHOTS_DIR, safe_filename)
res = delete_snapshots_safely(snapshot_ids=safe_ids)
return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))}
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Файл среза не найден на диске")
return FileResponse(
path=file_path,
filename=safe_filename,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
+173 -49
View File
@@ -8,21 +8,9 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<style>
/* Запрещаем браузеру насильно удерживать скролл внизу при появлении ответа */
* {
overflow-anchor: none !important;
}
#chat-messages-container, main {
overflow-anchor: none !important;
}
/* Воздух снизу для возможности поднятия вопроса на самый верх */
#chat-messages-container {
padding-bottom: clamp(400px, 85vh, 900px) !important;
}
/* Шрифт сообщений чата */
* { overflow-anchor: none !important; }
#chat-messages-container, main { overflow-anchor: none !important; }
#chat-messages-container { padding-bottom: clamp(400px, 85vh, 900px) !important; }
#chat-messages-container .message-content,
#chat-messages-container .text-xs,
#chat-messages-container .text-sm {
@@ -33,31 +21,167 @@
#chat-messages-container code {
font-size: 13.5px !important;
}
.user-chat-bubble {
scroll-margin-top: 24px !important;
}
.user-chat-bubble { scroll-margin-top: 24px !important; }
</style>
</head>
<body class="h-full flex flex-col font-sans antialiased text-slate-800 bg-slate-100 selection:bg-indigo-500 selection:text-white">
<div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">
<!-- ЛЕВАЯ КОЛОНКА (ДИНАМИЧЕСКИЙ САЙДБАР) -->
<aside class="w-80 md:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
<!-- ЛЕВАЯ КОЛОНКА (САЙДБАР) -->
<aside class="w-[420px] md:w-[500px] bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
<!-- ДИНАМИЧЕСКИЙ ТАБ-БАР ХАБОВ И ПОДВКЛАДОК -->
<div id="sidebar-dynamic-header" class="shrink-0 bg-slate-50 border-b border-slate-200"></div>
<!-- НАВИГАЦИОННЫЙ ТАБ-БАР -->
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-1 py-1 shrink-0">
<button data-tab="tasks" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-list-check block text-sm mb-0.5"></i> Задачи
</button>
<button data-tab="snapshots" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-camera block text-sm mb-0.5"></i> Срезы
</button>
<button data-tab="registries" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-address-book block text-sm mb-0.5"></i> Реестры
</button>
<button data-tab="prompts" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-terminal block text-sm mb-0.5"></i> Промпт
</button>
<button data-tab="context" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-comments block text-sm mb-0.5"></i> Контекст
</button>
</div>
<!-- ДИНАМИЧЕСКИЙ КОНТЕНТНЫЙ СЛОТ -->
<div id="sidebar-dynamic-content" class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...
<!-- РАБОЧИЕ ОБЛАСТИ ВКЛАДОК -->
<div class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
<!-- 1. ЗАДАЧИ -->
<div id="sidebar-view-tasks" class="sidebar-view w-full h-full flex flex-col">
<div id="tasks-list" class="space-y-2 flex-1"></div>
</div>
<!-- 2. СРЕЗЫ И ОТЧЕТЫ -->
<div id="sidebar-view-snapshots" class="sidebar-view w-full space-y-3 hidden">
<!-- ПЕРИОД ВЫБОРКИ СРЕЗОВ -->
<div class="p-2.5 bg-slate-50 rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-slate-600 uppercase flex items-center gap-1.5">
<i class="fa-regular fa-calendar text-indigo-500"></i> Период срезов:
</span>
<button onclick="loadSnapshotsView()" class="text-xs text-indigo-600 hover:text-indigo-800 font-semibold flex items-center gap-1 transition">
<i class="fa-solid fa-rotate-right text-[10px]"></i> Найти
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-[10px] text-slate-400 block mb-0.5">С даты:</label>
<input type="date" id="snapshots-date-from" onchange="loadSnapshotsView()"
class="w-full text-xs px-2 py-1.5 bg-white border border-slate-200 rounded-lg text-slate-700 font-mono focus:outline-none focus:border-indigo-500 cursor-pointer">
</div>
<div>
<label class="text-[10px] text-slate-400 block mb-0.5">По дату:</label>
<input type="date" id="snapshots-date-to" onchange="loadSnapshotsView()"
class="w-full text-xs px-2 py-1.5 bg-white border border-slate-200 rounded-lg text-slate-700 font-mono focus:outline-none focus:border-indigo-500 cursor-pointer">
</div>
</div>
<div class="flex items-center gap-1.5 pt-1 border-t border-slate-200/60">
<button onclick="setSnapshotDatePreset('today')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
Сегодня
</button>
<button onclick="setSnapshotDatePreset('yesterday')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
Вчера
</button>
<button onclick="setSnapshotDatePreset('days3')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
3 дня
</button>
<button onclick="setSnapshotDatePreset('days7')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
7 дней
</button>
</div>
</div>
<!-- Панель создания срезов -->
<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="text-[11px] font-bold text-slate-600 uppercase flex items-center gap-1.5">
<i class="fa-regular fa-clock text-indigo-500"></i> Создать срез на дату/время:
</div>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="manual-snapshot-date" placeholder="ДД.ММ.ГГГГ" class="text-xs px-2.5 py-1.5 border border-slate-200 rounded-lg text-center font-mono">
<input type="text" id="manual-snapshot-time" placeholder="ЧЧ:ММ" class="text-xs px-2.5 py-1.5 border border-slate-200 rounded-lg text-center font-mono">
</div>
<button id="btn-create-snapshot" onclick="createSnapshotManual()" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold transition flex items-center justify-center gap-1.5">
<i class="fa-solid fa-camera"></i> Сделать срез
</button>
</div>
<!-- Панель On-Demand генерации отчетов -->
<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="text-[11px] font-bold text-slate-700 uppercase flex items-center gap-1.5">
<i class="fa-solid fa-file-excel text-emerald-600"></i> Сформировать отчет:
</div>
<div class="grid grid-cols-3 gap-1.5">
<button onclick="generateReportDirect('SVODKA')" class="py-2 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-table-list"></i> Сводка
</button>
<button onclick="generateReportDirect('SIMPLIFIED')" class="py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-file-lines"></i> Упрощ.
</button>
<button onclick="generateReportDirect('DETAILED')" class="py-2 bg-purple-50 hover:bg-purple-100 text-purple-700 border border-purple-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-chart-column"></i> Детальн.
</button>
</div>
</div>
<div class="flex items-center justify-between pt-1">
<span id="snapshots-count-badge" class="text-xs font-bold text-slate-700">Срезы в базе: 0</span>
<button onclick="loadSnapshotsView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить список">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="snapshots-list" class="space-y-2"></div>
</div>
<!-- 3. РЕЕСТРЫ -->
<div id="sidebar-view-registries" class="sidebar-view w-full space-y-3 hidden">
<div class="grid grid-cols-2 gap-1 p-1 bg-slate-200/60 rounded-xl">
<button data-subtab="exceptions" onclick="switchRegistrySubTab('exceptions')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-user-shield text-[10px]"></i> Исключения
</button>
<button data-subtab="remote" onclick="switchRegistrySubTab('remote')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-house-laptop text-[10px]"></i> Удаленщики
</button>
<button data-subtab="local_trip" onclick="switchRegistrySubTab('local_trip')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-location-dot text-[10px]"></i> Мест. командир.
</button>
<button data-subtab="other" onclick="switchRegistrySubTab('other')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-clipboard-question text-[10px]"></i> Иное
</button>
</div>
<div id="registry-content-container" class="space-y-3"></div>
</div>
<!-- 4. ПРОМПТ -->
<div id="sidebar-view-prompts" class="sidebar-view w-full space-y-3 hidden">
<div class="flex items-center justify-between pb-1 border-b border-slate-200">
<span class="text-xs font-bold text-slate-800">Системный промпт (Ollama)</span>
<button onclick="loadPromptsView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="prompts-content-container" class="space-y-2"></div>
</div>
<!-- 5. КОНТЕКСТ -->
<div id="sidebar-view-context" class="sidebar-view w-full space-y-3 hidden">
<div class="flex items-center justify-between pb-1 border-b border-slate-200">
<span class="text-xs font-bold text-slate-800">Мониторинг сессии</span>
<button onclick="loadContextView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="context-content-container" class="space-y-2"></div>
</div>
</div>
<!-- ПОДВАЛ САЙДБАРА: ПРОФИЛЬ, НАСТРОЙКИ, АДМИНКА И ВЫХОД -->
<!-- ПОДВАЛ -->
<div class="p-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
@@ -73,11 +197,9 @@
<button id="admin-panel-btn" type="button" onclick="openAdminModal()" class="hidden p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-slate-200 rounded-lg transition" title="Управление пользователями">
<i class="fa-solid fa-users-gear text-sm"></i>
</button>
<button type="button" onclick="openProfileModal()" class="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200 rounded-lg transition" title="Сменить пароль">
<i class="fa-solid fa-gear text-sm"></i>
</button>
<button type="button" onclick="AuthManager.logout()" class="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition" title="Выйти из системы">
<i class="fa-solid fa-arrow-right-from-bracket text-sm"></i>
</button>
@@ -100,6 +222,13 @@
<p class="text-[10px] text-slate-400">Система интеллектуального аудита и контроля СКУД / 1С</p>
</div>
</div>
<div class="flex items-center gap-2">
<button onclick="window.openPresenceModal()" class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-bold border border-emerald-200 transition shadow-xs">
<i class="fa-solid fa-building-user text-xs"></i>
<span>Кто в здании</span>
</button>
</div>
</header>
<!-- ЛЕНТА ЧАТА -->
@@ -150,25 +279,20 @@
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
<div id="modals-container"></div>
<!-- ГЛОБАЛЬНАЯ DRAG-AND-DROP ЗОНА -->
<div id="global-drag-overlay"
class="fixed inset-0 bg-indigo-900/40 backdrop-blur-xs z-50 hidden flex items-center justify-center pointer-events-none transition-all duration-200">
<div class="bg-white border-2 border-dashed border-indigo-500 rounded-3xl p-10 flex flex-col items-center gap-3 shadow-2xl scale-100 transition-transform">
<div class="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center text-3xl shadow-inner">
<i class="fa-solid fa-cloud-arrow-up animate-bounce"></i>
</div>
<div class="text-base font-bold text-slate-800">Перетащите файл в любую точку окна</div>
<div class="text-xs text-slate-500 font-medium">PDF-документы, сканы, отчеты или изображения</div>
</div>
</div>
<script src="/static/js/auth.js?v=2.6.0"></script>
<script src="/static/js/tasks.js?v=2.6.0"></script>
<script src="/static/js/manual_absences.js?v=2.6.0"></script>
<script src="/static/js/snapshot_inspector.js?v=2.6.0"></script>
<script src="/static/js/sidebar/core.js?v=2.6.0"></script>
<script src="/static/js/sidebar/registries.js?v=2.6.0"></script>
<script src="/static/js/sidebar/snapshots.js?v=2.6.0"></script>
<script src="/static/js/sidebar/prompts_context.js?v=2.6.0"></script>
<!-- СКРИПТЫ -->
<script src="/static/js/auth.js?v=2.5.8"></script>
<script src="/static/js/tasks.js?v=2.5.8"></script>
<script src="/static/js/manual_absences.js?v=2.5.8"></script>
<script src="/static/js/sidebar.js?v=2.5.8"></script>
<script src="/static/js/chat/task_widget.js?v=2.5.8"></script>
<script src="/static/js/chat/core.js?v=2.5.8"></script>
<script src="/static/js/app.js?v=2.5.8"></script>
<script src="/static/js/presence.js?v=2.6.0"></script>
<script src="/static/js/chat/task_widget.js?v=2.6.0"></script>
<script src="/static/js/chat/core.js?v=2.6.0"></script>
<script src="/static/js/app.js?v=2.6.0"></script>
</body>
</html>
+25 -6
View File
@@ -28,7 +28,9 @@ async function loadModals() {
'exception_modal.html',
'auth_modal.html',
'profile_modal.html',
'admin_modal.html'
'admin_modal.html',
'snapshot_inspector_modal.html',
'presence_modal.html'
];
const container = document.getElementById('modals-container');
@@ -36,7 +38,7 @@ async function loadModals() {
for (const file of modalFiles) {
try {
const res = await fetch(`/static/modals/${file}?v=2.5.8`);
const res = await fetch(`/static/modals/${file}?v=2.6.0`);
if (res.ok) {
const html = await res.text();
container.insertAdjacentHTML('beforeend', html);
@@ -99,7 +101,7 @@ async function handleLoginSubmit(e) {
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
hideAuthModal();
updateUIState();
if (window.SidebarManager) SidebarManager.setHub('TASKS');
if (window.SidebarManager) SidebarManager.switchTab('tasks');
} else {
const err = await res.json();
if (errEl) {
@@ -272,9 +274,14 @@ function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom
const fromInput = document.getElementById("rw-date-from");
const toInput = document.getElementById("rw-date-to");
const errEl = document.getElementById("rw-error");
const suggBox = document.getElementById("rw-fio-suggestions");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (suggBox) {
suggBox.classList.add("hidden");
suggBox.innerHTML = "";
}
if (modeInput) modeInput.value = mode;
if (mode === 'EDIT') {
@@ -340,7 +347,7 @@ async function handleRemoteWorkerSubmit(e) {
if (res.ok) {
closeRemoteWorkerModal();
if (window.SidebarManager) SidebarManager.renderContent();
if (window.SidebarManager) SidebarManager.switchRegistrySubTab('remote');
} else {
const err = await res.json();
if (errEl) {
@@ -370,7 +377,19 @@ document.addEventListener("DOMContentLoaded", async () => {
setupStaffAutocomplete(excInput, "exception-suggestions");
}
// 3. Авто-высота поля ввода команд
// 3. Инициализация автокомплита для модалки удаленщиков
const rwFioInput = document.getElementById("rw-fio");
if (rwFioInput && typeof setupStaffAutocomplete === "function") {
setupStaffAutocomplete(rwFioInput, "rw-fio-suggestions");
}
//3.1 Автокомплит для местных командировок и иного
const maFioInput = document.getElementById("manual-absence-fio-input");
if (maFioInput && typeof setupStaffAutocomplete === "function") {
setupStaffAutocomplete(maFioInput, "manual-absence-suggestions");
}
// 4. Авто-высота поля ввода команд
const userInputEl = document.getElementById("user-input");
if (userInputEl) {
userInputEl.addEventListener("input", function() {
@@ -380,7 +399,7 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
// 4. Проверка сессии пользователя
// 5. Проверка сессии пользователя
if (IS_GUEST) {
hideAuthModal();
updateUIState();
+124 -140
View File
@@ -1,8 +1,8 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/manual_absences.js
* ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП
* и мгновенная синхронизация с боковой панелью SidebarManager.
* ROLE: Модальные окна "Мест. командир.", "Иное", универсальный автокомплит ФИО
* и синхронизация с боковой панелью SidebarManager.
* ===============================================================================
*/
@@ -21,18 +21,24 @@ async function loadAbsenceReasons() {
}
}
function openManualAbsenceModal(type) {
// ⭐️ Открытие окна: режим создания (item = null) или редактирования (item = {...})
function openManualAbsenceModal(type, editItem = null) {
activeAbsenceType = type;
const isTrip = type === 'LOCAL_TRIP';
const titleEl = document.getElementById('manual-absence-modal-title');
const isTrip = (type === 'LOCAL_TRIP');
const modal = document.getElementById('manual-absence-modal');
const titleEl = document.getElementById('manual-absence-modal-title-text');
const reasonBlock = document.getElementById('manual-absence-reason-block');
const reasonSelect = document.getElementById('manual-absence-reason-select');
if (titleEl) {
titleEl.innerHTML = isTrip
? '<i class="fa-solid fa-location-dot text-indigo-600 mr-2"></i>Местная командировка'
: '<i class="fa-solid fa-clipboard-list text-purple-600 mr-2"></i>Иные причины отсутствия';
}
const idInput = document.getElementById('manual-absence-id');
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 errEl = document.getElementById('manual-absence-error');
if (errEl) errEl.classList.add('hidden');
if (reasonBlock && reasonSelect) {
if (isTrip) {
@@ -43,31 +49,37 @@ function openManualAbsenceModal(type) {
}
}
// Сброс полей ввода
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');
const today = new Date().toISOString().split('T')[0];
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;
if (editItem) {
// Режим РЕДАКТИРОВАНИЯ
if (titleEl) titleEl.innerText = isTrip ? 'Изменение сроков командировки' : 'Изменение сроков отсутствия';
if (idInput) idInput.value = editItem.id;
if (fioInput) {
fioInput.value = editItem.fio;
fioInput.readOnly = true;
fioInput.classList.add('bg-slate-100', 'text-slate-500', 'cursor-not-allowed');
}
if (deptInput) deptInput.value = editItem.department || '';
if (posInput) posInput.value = editItem.position || '';
if (startDateInput) startDateInput.value = editItem.date_start ? editItem.date_start.replace(/\./g, '-') : today;
if (endDateInput) endDateInput.value = editItem.date_end ? editItem.date_end.replace(/\./g, '-') : today;
if (reasonSelect && editItem.reason) reasonSelect.value = editItem.reason;
} else {
// Режим СОЗДАНИЯ
if (titleEl) titleEl.innerText = isTrip ? 'Добавить в командировки' : 'Добавить отсутствие';
if (idInput) idInput.value = '';
if (fioInput) {
fioInput.value = '';
fioInput.readOnly = false;
fioInput.classList.remove('bg-slate-100', 'text-slate-500', 'cursor-not-allowed');
}
if (deptInput) deptInput.value = '';
if (posInput) posInput.value = '';
if (startDateInput) startDateInput.value = today;
if (endDateInput) endDateInput.value = today;
}
loadManualAbsencesTable();
const modal = document.getElementById('manual-absence-modal');
if (modal) modal.classList.remove('hidden');
}
@@ -76,7 +88,65 @@ function closeManualAbsenceModal() {
if (modal) modal.classList.add('hidden');
}
// Живой автокомплит ФИО из базы zup_staff
async function submitManualAbsence(event) {
if (event) event.preventDefault();
const idVal = document.getElementById('manual-absence-id')?.value.trim();
const fio = document.getElementById('manual-absence-fio-input')?.value.trim();
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 errEl = document.getElementById('manual-absence-error');
if (!fio) {
if (errEl) { errEl.innerText = 'Укажите ФИО сотрудника'; errEl.classList.remove('hidden'); }
return;
}
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 {
let res;
if (idVal) {
// Редактирование
res = await fetch(`/api/v1/manual-absences/${idVal}`, {
method: 'PUT',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ id: parseInt(idVal), date_start: startDateVal, date_end: endDateVal })
});
} else {
// Создание
res = await fetch('/api/v1/manual-absences/', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
if (res.ok) {
closeManualAbsenceModal();
if (typeof loadManualAbsencesView === 'function') {
loadManualAbsencesView(activeAbsenceType);
}
} else {
const data = await res.json();
if (errEl) { errEl.innerText = data.detail || 'Ошибка сохранения'; errEl.classList.remove('hidden'); }
}
} catch (e) {
if (errEl) { errEl.innerText = 'Сетевая ошибка при сохранении'; errEl.classList.remove('hidden'); }
}
}
// Универсальный автокомплит сотрудников
let searchTimeout = null;
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
const box = document.getElementById(suggestionsBoxId);
@@ -103,7 +173,7 @@ function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
box.innerHTML = items.map(it => `
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
onclick="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}')">
onmousedown="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}', '${inputEl.id}', '${suggestionsBoxId}')">
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
</div>
@@ -114,118 +184,32 @@ function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
}
}, 200);
});
document.addEventListener('click', (e) => {
if (!inputEl.contains(e.target) && !box.contains(e.target)) {
box.classList.add('hidden');
}
});
}
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');
function selectStaffSuggestion(fio, dept, pos, targetInputId, boxId) {
const targetFioEl = document.getElementById(targetInputId);
if (targetFioEl) targetFioEl.value = fio;
if (fioInput) fioInput.value = fio;
if (deptInput) deptInput.value = dept;
if (posInput) posInput.value = pos;
if (targetInputId === 'rw-fio') {
const rwDept = document.getElementById('rw-dept');
if (rwDept && dept && dept !== '—') rwDept.value = dept;
} else if (targetInputId === 'manual-absence-fio-input') {
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
if (deptInput) deptInput.value = dept;
if (posInput) posInput.value = pos;
}
const box = document.getElementById(boxId);
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 = '<tr><td colspan="5" class="text-center p-4 text-xs text-slate-400">Нет активных записей</td></tr>';
return;
}
tableContainer.innerHTML = items.map(it => `
<tr class="border-b border-slate-100 text-xs hover:bg-slate-50">
<td class="p-2 font-bold text-slate-800">${escapeHtml(it.fio)}</td>
<td class="p-2 text-slate-500">${escapeHtml(it.department || '—')}</td>
<td class="p-2 text-slate-600">${escapeHtml(it.reason)}</td>
<td class="p-2 text-center text-slate-500 font-mono text-[11px]">${it.date_start || '—'} / ${it.date_end || '—'}</td>
<td class="p-2 text-center">
<button onclick="deleteManualAbsenceRecord(${it.id})" class="text-slate-400 hover:text-rose-600 transition p-1" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</td>
</tr>
`).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'
);
});
+176
View File
@@ -0,0 +1,176 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/presence.js
* ROLE: Контроллер экрана оперативного мониторинга («Кто в здании прямо сейчас»).
* ===============================================================================
*/
window.PresenceManager = {
records: [],
activeTab: 'ALL',
open() {
const modal = document.getElementById('presence-modal');
if (modal) {
modal.classList.remove('hidden');
this.bindEventsOnce();
this.loadData(false);
}
},
close() {
const modal = document.getElementById('presence-modal');
if (modal) modal.classList.add('hidden');
},
bindEventsOnce() {
if (this._eventsBound) return;
this._eventsBound = true;
document.getElementById('btn-close-presence-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-presence-force-refresh')?.addEventListener('click', () => this.loadData(true));
document.querySelectorAll('.presence-tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.presence-tab-btn').forEach(b => {
b.classList.remove('bg-white', 'shadow-xs', 'text-slate-800');
b.classList.add('text-slate-600');
});
const target = e.currentTarget;
target.classList.add('bg-white', 'shadow-xs', 'text-slate-800');
target.classList.remove('text-slate-600');
this.activeTab = target.getAttribute('data-tab') || 'ALL';
this.applyFilters();
});
});
document.getElementById('presence-search-input')?.addEventListener('input', () => this.applyFilters());
document.getElementById('presence-dept-filter')?.addEventListener('change', () => this.applyFilters());
},
async loadData(forceRefresh = false) {
const subtitle = document.getElementById('presence-modal-subtitle');
const btnRefresh = document.getElementById('btn-presence-force-refresh');
if (btnRefresh && forceRefresh) {
btnRefresh.disabled = true;
btnRefresh.innerHTML = '<span>⏳</span><span>Опрос СКУД...</span>';
}
try {
const url = `/api/v1/presence/live?force_refresh=${forceRefresh}`;
const res = await fetch(url, { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка ответа сервера');
const data = await res.json();
this.render(data);
} catch (err) {
console.error('[Presence] Ошибка загрузки:', err);
if (subtitle) subtitle.textContent = 'Ошибка загрузки данных присутствия';
} finally {
if (btnRefresh) {
btnRefresh.disabled = false;
btnRefresh.innerHTML = '<span>🔄</span><span>Запросить из СКУД</span>';
}
}
},
render(data) {
this.records = data.records || [];
const m = data.metrics || {};
// Заголовок
const subtitle = document.getElementById('presence-modal-subtitle');
const sourceLabel = data.data_source === 'LIVE_MSSQL' ? '🟢 Онлайн срез Орион' : '⏱️ Локальная база СКУД';
if (subtitle) {
subtitle.textContent = `${sourceLabel} на ${data.latest_event_time || data.timestamp} | Всего в штате 1С: ${m.total_staff || 0} чел.`;
}
// Плашки метрик (строго сбалансированы)
document.getElementById('metric-total').textContent = m.total_staff || 0;
document.getElementById('metric-inside').textContent = m.inside || 0;
document.getElementById('metric-outside').textContent = m.outside || 0;
document.getElementById('metric-remote').textContent = m.remote || 0;
document.getElementById('metric-absence').textContent = m.official_absence || 0;
document.getElementById('metric-not-entered').textContent = m.not_entered || 0;
document.getElementById('metric-excluded').textContent = m.excluded || 0;
// Счетчики на табах
document.getElementById('tab-cnt-all').textContent = m.total_staff || 0;
document.getElementById('tab-cnt-inside').textContent = m.inside || 0;
document.getElementById('tab-cnt-outside').textContent = m.outside || 0;
document.getElementById('tab-cnt-remote').textContent = m.remote || 0;
document.getElementById('tab-cnt-absence').textContent = m.official_absence || 0;
document.getElementById('tab-cnt-not-entered').textContent = m.not_entered || 0;
document.getElementById('tab-cnt-excluded').textContent = m.excluded || 0;
// Выпадающий список подразделений
const deptSelect = document.getElementById('presence-dept-filter');
if (deptSelect) {
const depts = Array.from(new Set(this.records.map(r => r.department).filter(Boolean))).sort();
deptSelect.innerHTML = '<option value="ALL">Все подразделения</option>' +
depts.map(d => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join('');
}
this.applyFilters();
},
applyFilters() {
const search = (document.getElementById('presence-search-input')?.value || '').toLowerCase().trim();
const dept = document.getElementById('presence-dept-filter')?.value || 'ALL';
const filtered = this.records.filter(r => {
if (this.activeTab === 'INSIDE' && r.status !== 'INSIDE') return false;
if (this.activeTab === 'OUTSIDE' && r.status !== 'OUTSIDE') return false;
if (this.activeTab === 'REMOTE' && r.status !== 'REMOTE') return false;
if (this.activeTab === 'ABSENCE' && r.status !== 'OFFICIAL_ABSENCE') return false;
if (this.activeTab === 'NOT_ENTERED' && r.status !== 'NOT_ENTERED') return false;
if (this.activeTab === 'EXCLUDED' && r.status !== 'EXCLUDED') return false;
if (dept !== 'ALL' && r.department !== dept) return false;
if (search && !r.fio.toLowerCase().includes(search) && !r.position.toLowerCase().includes(search)) return false;
return true;
});
const tbody = document.getElementById('presence-table-body');
if (!tbody) return;
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="5" class="py-10 text-center text-slate-400">Сотрудники не найдены</td></tr>';
return;
}
tbody.innerHTML = filtered.map(r => {
let badgeClass = 'bg-slate-100 text-slate-700';
if (r.status === 'INSIDE') {
badgeClass = r.is_fligel
? 'bg-emerald-100 text-emerald-900 border border-emerald-300 font-bold'
: 'bg-emerald-100 text-emerald-800 font-bold';
} else if (r.status === 'OUTSIDE') {
badgeClass = 'bg-amber-100 text-amber-800 font-bold';
} else if (r.status === 'REMOTE') {
badgeClass = 'bg-blue-100 text-blue-800 font-semibold';
} else if (r.status === 'OFFICIAL_ABSENCE') {
badgeClass = 'bg-purple-100 text-purple-800 font-medium';
} else if (r.status === 'NOT_ENTERED') {
badgeClass = 'bg-rose-100 text-rose-800 font-semibold';
} else if (r.status === 'EXCLUDED') {
badgeClass = 'bg-slate-200 text-slate-600 font-mono text-[10px]';
}
return `
<tr class="hover:bg-slate-50 transition-colors">
<td class="py-2.5 px-3 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
<td class="py-2.5 px-3 text-slate-600 font-mono text-[11px]">${escapeHtml(r.department)}</td>
<td class="py-2.5 px-3 text-slate-500">${escapeHtml(r.position)}</td>
<td class="py-2.5 px-3 text-center">
<span class="px-2 py-0.5 rounded-md text-[11px] ${badgeClass}">${escapeHtml(r.status_label)}</span>
</td>
<td class="py-2.5 px-3 text-center text-slate-600 font-mono font-semibold">${escapeHtml(r.last_time || '—')}</td>
</tr>
`;
}).join('');
}
};
window.openPresenceModal = () => window.PresenceManager.open();
-673
View File
@@ -1,673 +0,0 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/sidebar.js
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2),
* модальным окном добавления исключений с автокомплитом из 1С:ЗУП.
* ===============================================================================
*/
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
window.SidebarManager = {
currentHub: 'TASKS',
currentSubTab: {
'REGISTRIES': 'REMOTE'
},
hubs: [
{ id: 'TASKS', label: 'Задачи', icon: 'fa-list-check' },
{ id: 'SNAPSHOTS', label: 'Срезы', icon: 'fa-camera' },
{ id: 'REGISTRIES', label: 'Реестры', icon: 'fa-address-book' },
{ id: 'PROMPT', label: 'Промпт', icon: 'fa-terminal' },
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
],
// Сетка реестров 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' }
]
},
init() {
this.renderHeader();
this.renderContent();
},
setHub(hubId) {
this.currentHub = hubId;
this.renderHeader();
this.renderContent();
},
setSubTab(subTabId) {
this.currentSubTab[this.currentHub] = subTabId;
this.renderHeader();
this.renderContent();
},
renderHeader() {
const headerContainer = document.getElementById("sidebar-dynamic-header");
if (!headerContainer) return;
// 1. Основные 5 Хабов
const hubsHtml = `
<div class="flex items-center border-b border-slate-200 bg-slate-50/80 px-1 pt-1.5 overflow-x-auto gap-0.5">
${this.hubs.map(h => {
const isActive = this.currentHub === h.id;
return `
<button onclick="SidebarManager.setHub('${h.id}')"
class="flex-1 py-1.5 px-1 flex flex-col items-center gap-1 border-b-2 font-bold text-[10px] transition ${
isActive
? 'border-indigo-600 text-indigo-600 bg-white rounded-t-lg shadow-sm'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:bg-slate-100/60 rounded-t-lg'
}">
<i class="fa-solid ${h.icon} text-xs"></i>
<span class="truncate">${h.label}</span>
</button>
`;
}).join('')}
</div>
`;
// 2. Подвкладки реестров (Сетка 2x2)
let subTabsHtml = '';
if (this.subTabs[this.currentHub]) {
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
subTabsHtml = `
<div class="grid grid-cols-2 gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
${this.subTabs[this.currentHub].map(st => {
const isSubActive = currentActiveSub === st.id;
return `
<button onclick="SidebarManager.setSubTab('${st.id}')"
class="py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
isSubActive
? 'bg-white text-indigo-700 shadow-sm'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
}">
<i class="fa-solid ${st.icon} text-[10px]"></i>
<span>${st.label}</span>
</button>
`;
}).join('')}
</div>
`;
}
headerContainer.innerHTML = hubsHtml + subTabsHtml;
},
renderContent() {
const contentContainer = document.getElementById("sidebar-dynamic-content");
if (!contentContainer) return;
contentContainer.scrollTop = 0;
switch (this.currentHub) {
case 'TASKS':
this.renderTasksView(contentContainer);
break;
case 'SNAPSHOTS':
this.renderSnapshotsView(contentContainer);
break;
case 'REGISTRIES':
this.renderRegistriesView(contentContainer);
break;
case 'PROMPT':
this.renderPromptView(contentContainer);
break;
case 'CONTEXT':
this.renderContextView(contentContainer);
break;
default:
contentContainer.innerHTML = `<div class="p-4 text-xs text-slate-400 text-center">Раздел в разработке</div>`;
}
},
// =========================================================================
// ХАБ 1: ЗАДАЧИ
// =========================================================================
renderTasksView(container) {
container.innerHTML = `
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
</div>
</div>
`;
if (window.loadTasks) {
window.loadTasks();
}
},
// =========================================================================
// ХАБ 2: СРЕЗЫ СКУД
// =========================================================================
async renderSnapshotsView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
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 = `<div class="text-center py-8 text-xs text-slate-400">Срезы СКУД не найдены</div>`;
return;
}
const itemsHtml = snaps.map(s => `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-bold text-slate-800">${escapeHtml(s.snapshot_id)}</span>
${s.is_final ? '<span class="px-1.5 py-0.2 rounded text-[9px] font-bold bg-amber-50 text-amber-700 border border-amber-200">Финал Y</span>' : ''}
</div>
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(s.snapshot_time)} · ${s.record_count || 0} зап.</div>
</div>
<button onclick="window.sendChatAction('покажи срез ${escapeHtml(s.snapshot_id)}')" class="px-2 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition" title="Открыть в чате">
Инспекция
</button>
</div>
`).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">Всего срезов: ${snaps.length}</span>
<button onclick="SidebarManager.renderSnapshotsView(document.getElementById('sidebar-dynamic-content'))" class="text-slate-400 hover:text-indigo-600 p-1" title="Обновить">
<i class="fa-solid fa-arrows-rotate text-xs"></i>
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">${itemsHtml}</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки срезов</div>`;
}
},
// =========================================================================
// ХАБ 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 = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>`;
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 `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-emerald-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(w.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(w.department || 'Все')}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="openRemoteWorkerModal('EDIT', '${escapeHtml(w.fio)}', '${escapeHtml(w.department || 'Все')}', '${escapeHtml(w.date_from || '')}', '${escapeHtml(w.date_to || '')}')"
class="text-slate-400 hover:text-emerald-600 p-1.5 rounded-lg hover:bg-emerald-50 transition"
title="Изменить сроки удаленки">
<i class="fa-solid fa-pen-to-square text-xs"></i>
</button>
<button onclick="SidebarManager.deleteRemoteWorker('${escapeHtml(w.fio)}')"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${workers.length} чел.</span>
<button onclick="openRemoteWorkerModal('ADD')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${workers.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список удаленщиков пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра удаленщиков</div>`;
}
},
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 = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка исключений...</div>`;
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: 'Исключенные должности' },
{ key: 'turnstile_fio', title: 'Пр. турникет (ФИО)', badge: 'Оба турникета' },
{ key: 'turnstile_departments', title: 'Пр. турникет (Отделы)', badge: 'Оба турникета' }
];
const html = categories.map(cat => {
const items = data[cat.key] || [];
const isTurnstile = cat.key.startsWith('turnstile_');
const badgeHtml = cat.badge
? `<span class="px-1.5 py-0.2 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">${cat.badge}</span>`
: '';
return `
<div class="bg-white border ${isTurnstile ? 'border-emerald-200/80 bg-emerald-50/10' : 'border-slate-200'} rounded-xl p-3 flex flex-col gap-2 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
${badgeHtml}
</div>
<button onclick="openExceptionModal('${cat.key}', '${cat.title}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
+ Добавить
</button>
</div>
<div class="flex flex-wrap gap-1">
${items.map(it => `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] ${isTurnstile ? 'bg-emerald-50 text-emerald-800 border border-emerald-200' : 'bg-slate-100 text-slate-700 border border-slate-200'}">
${escapeHtml(it)}
<button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
</span>
`).join('') || '<span class="text-[10px] text-slate-400">Пусто</span>'}
</div>
</div>
`;
}).join('');
container.innerHTML = `<div class="p-2 flex flex-col gap-2 max-h-[75vh] overflow-y-auto">${html}</div>`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки исключений</div>`;
}
},
async deleteExceptionItem(category, value) {
if (!confirm(`Удалить "${value}" из реестра?`)) 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 = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка ${typeLabel}...</div>`;
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 `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(it.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(it.department || 'Все')} · ${badgeText}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="SidebarManager.deleteManualAbsenceRecord(${it.id})"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${items.length} чел.</span>
<button onclick="openManualAbsenceModal('${absenceType}')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${items.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра</div>`;
}
},
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 = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-1.5">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-sliders text-indigo-600"></i> Инструкции и регламенты ИИ
</span>
<span>Управление системными директивами, базой знаний и правилами арбитража кадровых аномалий СКУД и 1С.</span>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('покажи системный промпт')"
class="w-full py-2 px-3 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-terminal text-xs"></i>
<span>Показать системный промпт</span>
</button>
<button onclick="window.sendChatAction('покажи правила компании')"
class="w-full py-2 px-3 bg-white hover:bg-slate-50 active:bg-slate-100 text-slate-700 border border-slate-300 rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-book-bookmark text-emerald-600 text-xs"></i>
<span>База знаний и правила компании</span>
</button>
</div>
</div>
`;
},
// =========================================================================
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ
// =========================================================================
renderContextView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-2">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-brain text-indigo-600"></i> Управление памятью чата
</span>
<div class="flex flex-col gap-1.5 pt-1 border-t border-slate-100">
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-amber-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Мягкая очистка:</span>
<span class="text-slate-500"> удаляет только служебные транзакции (карточки срезов, временные превью промпта, промежуточные подтверждения). Смысловой диалог пользователя сохраняется.</span>
</div>
</div>
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-rose-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Полный сброс:</span>
<span class="text-slate-500"> полностью стирает контекст активной сессии из базы данных и очищает окно чата. Используется при переходе к новой дате или новой теме анализа.</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('очисти контекст')"
class="w-full py-2 px-3 bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-broom text-xs"></i>
<span>Мягкая очистка контекста</span>
</button>
<button onclick="SidebarManager.handleFullSessionReset()"
class="w-full py-2 px-3 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-trash-arrow-up text-xs"></i>
<span>Полный сброс сессии</span>
</button>
</div>
</div>
`;
},
async handleFullSessionReset() {
if (!confirm("Вы действительно хотите полностью очистить историю диалога и сбросить сессию чата?")) {
return;
}
const chatContainer = document.getElementById("chat-messages-container");
if (chatContainer) {
chatContainer.innerHTML = `
<div class="flex gap-3 max-w-4xl mx-auto w-full">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-xs"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-xs text-slate-700 leading-relaxed">
Сессия чата очищена. Память ассистента сброшена. Задайте новый вопрос или команду.
</div>
</div>
</div>
`;
}
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);
}
}
};
// ============================================================================
// МОДАЛЬНОЕ ОКНО ИСКЛЮЧЕНИЙ И АВТОКОМПЛИТ 1С
// ============================================================================
window.openExceptionModal = function(category, title = "") {
const modal = document.getElementById("exception-modal");
const headerText = document.getElementById("exception-modal-header-text");
const catInput = document.getElementById("exception-category-input");
const valInput = document.getElementById("exception-value-input");
const labelEl = document.getElementById("exception-value-label");
const commentInput = document.getElementById("exception-comment-input");
const errEl = document.getElementById("exception-error-msg");
const suggestionsBox = document.getElementById("exception-suggestions");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (suggestionsBox) {
suggestionsBox.classList.add("hidden");
suggestionsBox.innerHTML = "";
}
if (catInput) catInput.value = category;
if (commentInput) commentInput.value = "";
if (valInput) valInput.value = "";
if (headerText) headerText.innerText = title || "Добавление в реестр";
if (labelEl && valInput) {
if (category.includes("fio")) {
labelEl.innerText = "ФИО сотрудника (автоподбор из 1С):";
valInput.placeholder = "Начните вводить фамилию...";
} else if (category.includes("department")) {
labelEl.innerText = "Подразделение:";
valInput.placeholder = "Например: ЭТО, ЛЦ, ОВК";
} else {
labelEl.innerText = "Должность:";
valInput.placeholder = "Например: Уборщик, Слесарь";
}
}
modal.classList.remove("hidden");
if (valInput) valInput.focus();
};
window.closeExceptionModal = function() {
const modal = document.getElementById("exception-modal");
if (modal) modal.classList.add("hidden");
};
window.submitExceptionModalForm = async function(e) {
e.preventDefault();
const category = document.getElementById("exception-category-input").value;
const value = document.getElementById("exception-value-input").value.trim();
const comment = document.getElementById("exception-comment-input")?.value.trim() || "";
const errEl = document.getElementById("exception-error-msg");
if (!value) return;
try {
const res = await fetch("/api/v1/exceptions/", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ category: category, value: value, comment: comment })
});
if (res.ok) {
closeExceptionModal();
if (window.SidebarManager) SidebarManager.renderContent();
} else {
const err = await res.json().catch(() => ({}));
if (errEl) {
errEl.innerText = err.detail || "Ошибка сохранения";
errEl.classList.remove("hidden");
}
}
} catch (err) {
if (errEl) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
};
let excSearchTimeout = null;
document.addEventListener("DOMContentLoaded", () => {
if (window.AuthManager && AuthManager.isAuthenticated()) {
SidebarManager.init();
}
const inputEl = document.getElementById("exception-value-input");
const box = document.getElementById("exception-suggestions");
if (inputEl && box) {
inputEl.addEventListener("input", function() {
const category = document.getElementById("exception-category-input")?.value || "";
if (!category.includes("fio")) {
box.classList.add("hidden");
return;
}
const val = this.value.trim();
clearTimeout(excSearchTimeout);
if (val.length < 2) {
box.classList.add("hidden");
box.innerHTML = "";
return;
}
excSearchTimeout = 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 => `
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
onclick="selectExceptionStaff('${escapeHtml(it.fio)}')">
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
</div>
`).join("");
box.classList.remove("hidden");
} catch (e) {
console.error(e);
}
}, 200);
});
}
});
window.selectExceptionStaff = function(fio) {
const inputEl = document.getElementById("exception-value-input");
const box = document.getElementById("exception-suggestions");
if (inputEl) inputEl.value = fio;
if (box) {
box.classList.add("hidden");
box.innerHTML = "";
}
};
+70
View File
@@ -0,0 +1,70 @@
/**
* Роутер табов сайдбара и общие утилиты.
*/
window.escapeHtml = window.escapeHtml || function (str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const SidebarManager = {
currentTab: 'tasks',
currentRegistrySubTab: 'exceptions',
init() {
this.bindEvents();
this.switchTab('tasks');
},
bindEvents() {
document.querySelectorAll('.sidebar-tab-btn, [data-tab]').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.currentTarget.dataset.tab;
if (tab) this.switchTab(tab);
});
});
},
switchTab(tabName) {
this.currentTab = tabName;
// Переключение стилей кнопок табов
document.querySelectorAll('.sidebar-tab-btn, [data-tab]').forEach(btn => {
const isActive = btn.dataset.tab === tabName;
btn.classList.toggle('text-indigo-600', isActive);
btn.classList.toggle('border-indigo-600', isActive);
btn.classList.toggle('font-bold', isActive);
btn.classList.toggle('text-slate-500', !isActive);
btn.classList.toggle('border-transparent', !isActive);
});
// Переключение видимости вьюх
document.querySelectorAll('.sidebar-view').forEach(view => {
view.classList.add('hidden');
});
const targetView = document.getElementById(`sidebar-view-${tabName}`);
if (targetView) targetView.classList.remove('hidden');
// Вызов профильного загрузчика
if (tabName === 'tasks' && typeof loadTasks === 'function') {
loadTasks();
} else if (tabName === 'snapshots' && typeof loadSnapshotsView === 'function') {
loadSnapshotsView();
} else if (tabName === 'registries' && typeof switchRegistrySubTab === 'function') {
switchRegistrySubTab(this.currentRegistrySubTab);
} else if (tabName === 'prompts' && typeof loadPromptsView === 'function') {
loadPromptsView();
} else if (tabName === 'context' && typeof loadContextView === 'function') {
loadContextView();
}
}
};
document.addEventListener('DOMContentLoaded', () => {
SidebarManager.init();
});
@@ -0,0 +1,100 @@
/**
* Модуль вкладок "Промпт" и "Контекст".
*/
async function loadPromptsView() {
const container = document.getElementById('prompts-content-container');
if (!container) return;
container.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка промпта...</div>`;
try {
const res = await fetch('/api/v1/chat', {
method: 'POST',
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ message: "покажи системный промпт" })
});
const data = await res.json();
const text = data.response || "Промпт не получен";
container.innerHTML = `
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs space-y-2">
<div class="text-[11px] font-bold text-slate-600 flex items-center justify-between">
<span>Текущий системный промпт</span>
<button onclick="window.sendChatAction && window.sendChatAction('action:open_editor')" class="text-indigo-600 hover:text-indigo-800 text-[10px]">
<i class="fa-solid fa-pen-to-square"></i> Редактор
</button>
</div>
<pre class="text-[11px] font-mono text-slate-700 bg-slate-50 p-2.5 rounded-lg overflow-x-auto whitespace-pre-wrap leading-relaxed max-h-[65vh] border border-slate-100">${escapeHtml(text)}</pre>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить системный промпт</div>`;
}
}
async function loadContextView() {
const container = document.getElementById('context-content-container');
if (!container) return;
container.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка сессии...</div>`;
try {
const res = await fetch('/api/v1/context/state?session_id=web_session_main', { headers: AuthManager.getAuthHeaders() });
const data = await res.json();
container.innerHTML = `
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-800">Сессия: web_session_main</span>
<span class="px-2 py-0.5 text-[10px] font-bold rounded-full ${data.active_state !== 'IDLE' ? 'bg-amber-100 text-amber-800' : 'bg-slate-100 text-slate-600'}">
${escapeHtml(data.active_state)}
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-center">
<div class="p-2 bg-slate-50 rounded-lg border border-slate-100">
<div class="text-xs font-bold text-slate-700">${data.total_messages || 0}</div>
<div class="text-[10px] text-slate-400">Всего сообщений</div>
</div>
<div class="p-2 bg-slate-50 rounded-lg border border-slate-100">
<div class="text-xs font-bold text-indigo-600">${data.ephemeral_messages || 0}</div>
<div class="text-[10px] text-slate-400">Служебных (UI)</div>
</div>
</div>
<div class="space-y-1.5 pt-2 border-t border-slate-100">
<button onclick="purgeEphemeralMessages()" class="w-full py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold transition flex items-center justify-center gap-1.5">
<i class="fa-solid fa-broom text-[11px]"></i> Очистить служебные карточки
</button>
<button onclick="clearAllChatContext()" class="w-full py-2 bg-rose-50 hover:bg-rose-100 text-rose-700 rounded-lg text-xs font-semibold transition flex items-center justify-center gap-1.5">
<i class="fa-solid fa-trash-can text-[11px]"></i> Полный сброс контекста
</button>
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить состояние сессии</div>`;
}
}
async function purgeEphemeralMessages() {
try {
const res = await fetch('/api/v1/context/purge-ephemeral', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: 'web_session_main' })
});
if (res.ok) loadContextView();
} catch (e) { alert('Ошибка сети'); }
}
async function clearAllChatContext() {
if (!confirm('Полностью очистить историю сообщений диалога?')) return;
try {
const res = await fetch('/api/v1/context/clear-all', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: 'web_session_main' })
});
if (res.ok) location.reload();
} catch (e) { alert('Ошибка сети'); }
}
@@ -0,0 +1,420 @@
/**
* Модуль вкладок "Реестры": исключения, удаленщики, командировки, флигель.
*/
window.openAddExceptionModal = function(category) {
const modal = document.getElementById("exception-modal");
const catInput = document.getElementById("exception-category-input");
const valInput = document.getElementById("exception-value-input");
const commInput = document.getElementById("exception-comment-input");
const headerEl = document.getElementById("exception-modal-header-text");
const labelEl = document.getElementById("exception-value-label");
const errEl = document.getElementById("exception-error-msg");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (catInput) catInput.value = category;
if (valInput) { valInput.value = ""; valInput.focus(); }
if (commInput) commInput.value = "";
const titles = {
'include_fio': 'Белый список (ФИО)',
'fio': 'Исключенный сотрудник (ФИО)',
'departments': 'Исключенный отдел',
'positions': 'Исключенная должность',
'turnstile_fio': 'Правый турникет (ФИО)',
'turnstile_departments': 'Правый турникет (Отделы)',
'fligel_fio': 'Флигель (ФИО)',
'fligel_departments': 'Флигель (Отделы)'
};
const isDept = category.includes('department');
const isPos = category.includes('position');
if (headerEl) headerEl.innerText = "Добавить в " + (titles[category] || "реестр");
if (labelEl) {
labelEl.innerText = isDept ? 'Название подразделения:' : (isPos ? 'Название должности:' : 'ФИО сотрудника:');
}
modal.classList.remove("hidden");
};
window.closeExceptionModal = function() {
const modal = document.getElementById("exception-modal");
if (modal) modal.classList.add("hidden");
};
window.submitExceptionModalForm = async function(e) {
e.preventDefault();
const cat = document.getElementById("exception-category-input")?.value;
const val = document.getElementById("exception-value-input")?.value.trim();
const comm = document.getElementById("exception-comment-input")?.value.trim() || "";
const errEl = document.getElementById("exception-error-msg");
if (!cat || !val) {
if (errEl) { errEl.innerText = "Заполните поле"; errEl.classList.remove("hidden"); }
return;
}
try {
const res = await fetch("/api/v1/exceptions", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ category: cat, value: val, comment: comm })
});
if (res.ok) {
closeExceptionModal();
loadExceptionsView();
} else {
const data = await res.json();
if (errEl) { errEl.innerText = data.detail || "Ошибка сохранения"; errEl.classList.remove("hidden"); }
}
} catch (err) {
if (errEl) { errEl.innerText = "Ошибка соединения"; errEl.classList.remove("hidden"); }
}
};
function switchRegistrySubTab(subTab) {
if (window.SidebarManager) {
SidebarManager.currentRegistrySubTab = subTab;
}
document.querySelectorAll('.registry-subtab-btn').forEach(btn => {
const isActive = btn.dataset.subtab === subTab;
btn.classList.toggle('text-indigo-600', isActive);
btn.classList.toggle('bg-white', isActive);
btn.classList.toggle('shadow-xs', isActive);
btn.classList.toggle('font-bold', isActive);
btn.classList.toggle('text-slate-600', !isActive);
});
if (subTab === 'exceptions') loadExceptionsView();
else if (subTab === 'remote') loadRemoteWorkersView();
else if (subTab === 'local_trip') loadManualAbsencesView('LOCAL_TRIP');
else if (subTab === 'other') loadManualAbsencesView('OTHER');
}
async function loadExceptionsView() {
const container = document.getElementById('registry-content-container');
if (!container) return;
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка правил...</div>';
try {
const res = await fetch('/api/v1/exceptions', { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка сети');
const data = await res.json();
renderExceptionsView(data || {});
} catch (e) {
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить реестры исключений</div>';
}
}
function renderExceptionsView(exceptions) {
const container = document.getElementById('registry-content-container');
if (!container) return;
const renderCard = (title, list, category, badgeClass, subBadge) => {
badgeClass = badgeClass || 'bg-slate-100 text-slate-700';
subBadge = subBadge || '';
let tags = '<span class="text-[11px] text-slate-400 italic">Список пуст</span>';
if (list && list.length > 0) {
tags = list.map(item => {
const val = escapeHtml(item);
return '<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-xs font-medium ' + badgeClass + '">' +
'<span>' + val + '</span>' +
'<button data-cat="' + category + '" data-val="' + val + '" class="btn-remove-exc text-slate-400 hover:text-rose-500 transition">' +
'<i class="fa-solid fa-xmark text-[10px]"></i>' +
'</button>' +
'</span>';
}).join('');
}
const count = list ? list.length : 0;
return '<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs mb-3">' +
'<div class="flex items-center justify-between mb-2">' +
'<div class="flex items-center gap-1.5">' +
'<span class="text-xs font-bold text-slate-800">' + title + ' (' + count + ')</span>' +
subBadge +
'</div>' +
'<button data-add-cat="' + category + '" class="btn-add-exc text-xs font-bold text-indigo-600 hover:text-indigo-800">+ Добавить</button>' +
'</div>' +
'<div class="flex flex-wrap gap-1.5">' + tags + '</div>' +
'</div>';
};
container.innerHTML =
renderCard('Белый список (ФИО)', exceptions.include_fio, 'include_fio', 'bg-indigo-50 text-indigo-700') +
renderCard('Исключенные сотрудники (ФИО)', exceptions.fio, 'fio') +
renderCard('Исключенные отделы', exceptions.departments, 'departments') +
renderCard('Исключенные должности', exceptions.positions, 'positions') +
renderCard('Пр. турникет (ФИО)', exceptions.turnstile_fio, 'turnstile_fio', 'bg-emerald-50 text-emerald-700 border border-emerald-200', '<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[9px] font-bold border border-emerald-200">Оба турникета</span>') +
renderCard('Пр. турникет (Отделы)', exceptions.turnstile_departments, 'turnstile_departments', 'bg-emerald-50 text-emerald-700 border border-emerald-200', '<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[9px] font-bold border border-emerald-200">Оба турникета</span>') +
renderCard('Флигель (ФИО)', exceptions.fligel_fio, 'fligel_fio', 'bg-indigo-50 text-indigo-700 border border-indigo-200', '<span class="px-1.5 py-0.5 bg-indigo-50 text-indigo-700 rounded text-[9px] font-bold border border-indigo-200">Дверь 23</span>') +
renderCard('Флигель (Отделы)', exceptions.fligel_departments, 'fligel_departments', 'bg-indigo-50 text-indigo-700 border border-indigo-200', '<span class="px-1.5 py-0.5 bg-indigo-50 text-indigo-700 rounded text-[9px] font-bold border border-indigo-200">Дверь 23</span>');
container.querySelectorAll('.btn-add-exc').forEach(btn => {
btn.addEventListener('click', e => {
openAddExceptionModal(e.currentTarget.getAttribute('data-add-cat'));
});
});
container.querySelectorAll('.btn-remove-exc').forEach(btn => {
btn.addEventListener('click', e => {
const target = e.currentTarget;
removeExceptionItem(target.getAttribute('data-cat'), target.getAttribute('data-val'));
});
});
}
async function removeExceptionItem(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) loadExceptionsView();
else alert('Ошибка при удалении');
} catch (e) {
alert('Ошибка сети');
}
}
async function loadRemoteWorkersView() {
const container = document.getElementById('registry-content-container');
if (!container) return;
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>';
try {
const res = await fetch('/api/v1/remote-workers', { headers: AuthManager.getAuthHeaders() });
const data = await res.json();
const workers = data.workers || [];
let listHtml = '';
if (workers.length === 0) {
listHtml = '<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">Список удаленщиков пуст</div>';
} else {
listHtml = workers.map(w => {
const fio = escapeHtml(w.fio);
const dept = escapeHtml(w.department || 'Все');
const dFrom = escapeHtml(w.date_from || '—');
const dTo = escapeHtml(w.date_to || 'бессрочно');
return '<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs text-xs flex flex-col gap-1.5">' +
'<div class="flex items-center justify-between">' +
'<span class="font-bold text-slate-800">' + fio + '</span>' +
'<div class="flex items-center gap-1.5">' +
'<button data-edit-fio="' + fio + '" data-edit-dept="' + (w.department || '') + '" data-edit-from="' + (w.date_from || '') + '" data-edit-to="' + (w.date_to || '') + '" class="btn-edit-remote text-slate-400 hover:text-emerald-600 transition" title="Редактировать"><i class="fa-solid fa-pen-to-square"></i></button>' +
'<button data-del-fio="' + fio + '" class="btn-del-remote text-slate-400 hover:text-rose-500 transition" title="Удалить"><i class="fa-solid fa-trash-can"></i></button>' +
'</div>' +
'</div>' +
'<div class="flex items-center justify-between text-[11px] text-slate-500">' +
'<span>' + dept + '</span>' +
'<span class="font-mono text-[10px] bg-slate-100 px-1.5 py-0.5 rounded">' + dFrom + ' по ' + dTo + '</span>' +
'</div>' +
'</div>';
}).join('');
}
container.innerHTML =
'<div class="flex items-center justify-between px-1 mb-2">' +
'<span class="text-xs font-bold text-slate-700">Удаленные сотрудники: ' + workers.length + '</span>' +
'<button id="btn-add-remote" class="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-xs transition">+ Добавить</button>' +
'</div>' +
'<div class="space-y-2">' + listHtml + '</div>';
const addBtn = document.getElementById('btn-add-remote');
if (addBtn) {
addBtn.addEventListener('click', () => openRemoteWorkerModal('ADD'));
}
container.querySelectorAll('.btn-edit-remote').forEach(btn => {
btn.addEventListener('click', e => {
const t = e.currentTarget;
openRemoteWorkerModal('EDIT', t.getAttribute('data-edit-fio'), t.getAttribute('data-edit-dept'), t.getAttribute('data-edit-from'), t.getAttribute('data-edit-to'));
});
});
container.querySelectorAll('.btn-del-remote').forEach(btn => {
btn.addEventListener('click', e => {
deleteRemoteWorkerItem(e.currentTarget.getAttribute('data-del-fio'));
});
});
} catch (e) {
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить удаленщиков</div>';
}
}
async function deleteRemoteWorkerItem(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) loadRemoteWorkersView();
} catch (e) {
alert('Ошибка сети');
}
}
async function loadManualAbsencesView(type) {
const container = document.getElementById('registry-content-container');
if (!container) return;
const isTrip = (type === 'LOCAL_TRIP');
const titleText = isTrip ? 'Местные командировки' : 'Иные причины';
const btnColor = isTrip ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-purple-600 hover:bg-purple-700';
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>';
try {
const res = await fetch('/api/v1/manual-absences/?type=' + encodeURIComponent(type), {
headers: AuthManager.getAuthHeaders()
});
const data = await res.json();
const items = data.items || [];
let listHtml = '';
if (items.length === 0) {
listHtml = '<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">Нет записей</div>';
} else {
listHtml = items.map(it => {
const fio = escapeHtml(it.fio);
const reason = escapeHtml(it.reason || '');
const dept = escapeHtml(it.department || '—');
const dStart = it.date_start ? it.date_start.replace(/-/g, '.') : '';
const dEnd = it.date_end ? it.date_end.replace(/-/g, '.') : '';
// Форматирование срока
let dateBadge = '';
if (dStart && dEnd && dStart === dEnd) {
dateBadge = dStart;
} else if (dStart && dEnd) {
dateBadge = `${dStart} — ${dEnd}`;
} else if (dEnd) {
dateBadge = `по ${dEnd}`;
} else if (dStart) {
dateBadge = `с ${dStart}`;
} else {
dateBadge = 'бессрочно';
}
const badgeBg = isTrip
? 'bg-indigo-50 text-indigo-700 border-indigo-200'
: 'bg-purple-50 text-purple-700 border-purple-200';
return `
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs text-xs flex flex-col gap-1.5 hover:border-slate-300 transition">
<!-- 1-я строка: ФИО и кнопки управления -->
<div class="flex items-center justify-between gap-2">
<span class="font-bold text-slate-800 truncate" title="${fio}">${fio}</span>
<div class="flex items-center gap-1 shrink-0">
<button data-edit-id="${it.id}" data-edit-fio="${fio}" data-edit-start="${it.date_start || ''}" data-edit-end="${it.date_end || ''}"
class="btn-edit-abs text-slate-400 hover:text-indigo-600 p-1 transition" title="Редактировать сроки">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button data-del-id="${it.id}"
class="btn-del-abs text-slate-400 hover:text-rose-500 p-1 transition" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</div>
${!isTrip && reason ? `<div class="text-[11px] text-slate-600 font-medium">${reason}</div>` : ''}
<!-- 2-я строка: Отдел слева и Четкий срок отсутствия справа -->
<div class="flex items-center justify-between text-[11px] pt-1 border-t border-slate-100">
<span class="text-slate-400 truncate max-w-[200px]" title="${dept}">${dept}</span>
<span class="font-mono text-[10px] px-2 py-0.5 rounded-md border font-semibold shrink-0 ${badgeBg}">
<i class="fa-regular fa-calendar-days mr-1 text-[9px]"></i>${dateBadge}
</span>
</div>
</div>
`;
}).join('');
}
container.innerHTML = `
<div class="flex items-center justify-between px-1 mb-2">
<span class="text-xs font-bold text-slate-700">${titleText}: ${items.length}</span>
<button id="btn-add-absence" class="px-2.5 py-1 ${btnColor} text-white rounded-lg text-xs font-bold shadow-xs transition">+ Добавить</button>
</div>
<div class="space-y-2">${listHtml}</div>
`;
const addBtn = document.getElementById('btn-add-absence');
if (addBtn) {
addBtn.addEventListener('click', () => openManualAbsenceModal(type));
}
// Слушатели кнопок редактирования
container.querySelectorAll('.btn-edit-abs').forEach(btn => {
btn.addEventListener('click', e => {
const t = e.currentTarget;
const id = t.getAttribute('data-edit-id');
const fio = t.getAttribute('data-edit-fio');
const start = t.getAttribute('data-edit-start');
const end = t.getAttribute('data-edit-end');
openManualAbsenceModal(type, {
id: id,
fio: fio,
date_start: start,
date_end: end
});
});
});
// Слушатели кнопок удаления
container.querySelectorAll('.btn-del-abs').forEach(btn => {
btn.addEventListener('click', e => {
deleteManualAbsenceRecord(e.currentTarget.getAttribute('data-del-id'), type);
});
});
} catch (e) {
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Ошибка загрузки</div>';
}
}
// Быстрое модальное окно / диалог изменения дат
async function openEditAbsenceDatesModal(id, fio, dateStart, dateEnd, type) {
const newEnd = prompt(`Укажите новую дату окончания для сотрудника:\n${fio}\n(формат ГГГГ-ММ-ДД):`, dateEnd || '');
if (newEnd === null) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, {
method: 'PUT',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
id: parseInt(id),
date_start: dateStart || null,
date_end: newEnd.trim() || null
})
});
if (res.ok) {
loadManualAbsencesView(type);
} else {
alert('Не удалось обновить дату');
}
} catch (err) {
alert('Ошибка сети при обновлении');
}
}
async function deleteManualAbsenceRecord(id, type) {
if (!confirm('Удалить эту запись?')) return;
try {
const res = await fetch('/api/v1/manual-absences/' + encodeURIComponent(id), {
method: 'DELETE',
headers: AuthManager.getAuthHeaders()
});
if (res.ok) loadManualAbsencesView(type);
} catch (e) {
alert('Ошибка сети');
}
}
@@ -0,0 +1,308 @@
/**
* Модуль вкладки "Срезы": список снапшотов по диапазону дат, создание, инспекция и генерация отчетов.
*/
// 1. Вспомогательные функции форматирования дат
function formatDateDDMMYYYY(d) {
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
function formatDateToISO(d) {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function isoToBackendFormat(isoStr) {
if (!isoStr) return '';
if (isoStr.includes('.')) return isoStr;
const [y, m, d] = isoStr.split('-');
return `${d}.${m}.${y}`;
}
// 2. Автозаполнение полей создания среза текущими датой и временем
function initManualSnapshotInputs() {
const now = new Date();
const dateInput = document.getElementById('manual-snapshot-date');
const timeInput = document.getElementById('manual-snapshot-time');
if (dateInput) {
dateInput.value = formatDateDDMMYYYY(now);
}
if (timeInput) {
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
timeInput.value = `${hh}:${mm}`;
}
}
// 3. Быстрые пресеты периода
window.setSnapshotDatePreset = function(preset) {
const today = new Date();
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (!fromInput || !toInput) return;
if (preset === 'today') {
const str = formatDateToISO(today);
fromInput.value = str;
toInput.value = str;
} else if (preset === 'yesterday') {
const y = new Date();
y.setDate(today.getDate() - 1);
const str = formatDateToISO(y);
fromInput.value = str;
toInput.value = str;
} else if (preset === 'days3') {
const start = new Date();
start.setDate(today.getDate() - 2);
fromInput.value = formatDateToISO(start);
toInput.value = formatDateToISO(today);
} else if (preset === 'days7') {
const start = new Date();
start.setDate(today.getDate() - 6);
fromInput.value = formatDateToISO(start);
toInput.value = formatDateToISO(today);
}
loadSnapshotsView();
};
// 4. Загрузка списка срезов с группировкой и сортировкой
async function loadSnapshotsView() {
// Гарантированно заполняем поля даты и времени создания среза
initManualSnapshotInputs();
const listContainer = document.getElementById('snapshots-list');
const countBadge = document.getElementById('snapshots-count-badge');
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (!listContainer) return;
const todayIso = formatDateToISO(new Date());
if (fromInput && !fromInput.value) fromInput.value = todayIso;
if (toInput && !toInput.value) toInput.value = todayIso;
const dateFrom = fromInput ? isoToBackendFormat(fromInput.value) : isoToBackendFormat(todayIso);
const dateTo = toInput ? isoToBackendFormat(toInput.value) : dateFrom;
listContainer.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
try {
const url = `/api/v1/snapshots?date_from=${encodeURIComponent(dateFrom)}&date_to=${encodeURIComponent(dateTo)}`;
const res = await fetch(url, { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка сети');
const data = await res.json();
const snapshots = data.snapshots || [];
if (countBadge) countBadge.innerText = `Срезы в базе: ${snapshots.length}`;
if (snapshots.length === 0) {
listContainer.innerHTML = `
<div class="text-center py-8 text-slate-400 text-xs space-y-2">
<p>За выбранный период срезы не найдены</p>
<button onclick="setSnapshotDatePreset('yesterday')" class="px-2.5 py-1 text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition font-medium shadow-2xs">
Показать за вчера
</button>
</div>
`;
return;
}
// Вспомогательный ключ для сортировки от новых к старым
function getSortKey(s) {
const sid = (s.snapshot_id || s.id || '').toUpperCase();
const digits = sid.replace(/\D/g, '');
if (sid.includes('FINAL') && digits.length >= 8) {
return `${digits.substring(0, 8)}_235959`;
}
return digits.padEnd(14, '0');
}
snapshots.sort((a, b) => getSortKey(b).localeCompare(getSortKey(a)));
// Группировка по дням
const groups = {};
snapshots.forEach(s => {
const sid = s.snapshot_id || s.id || '';
let groupDate = s.date || '';
if (!groupDate) {
const digits = sid.replace(/\D/g, '');
if (digits.length >= 8) {
const y = digits.substring(0, 4);
const m = digits.substring(4, 6);
const d = digits.substring(6, 8);
groupDate = `${d}.${m}.${y}`;
} else {
groupDate = 'Другие срезы';
}
}
if (!groups[groupDate]) groups[groupDate] = [];
groups[groupDate].push(s);
});
// Сортировка дат по убыванию
const sortedDates = Object.keys(groups).sort((d1, d2) => {
const parseDate = (str) => {
const p = str.split('.');
return p.length === 3 ? new Date(p[2], p[1] - 1, p[0]).getTime() : 0;
};
return parseDate(d2) - parseDate(d1);
});
let html = '';
for (const dateLabel of sortedDates) {
const items = groups[dateLabel];
html += `
<div class="pt-2 pb-1 flex items-center gap-2">
<span class="text-[11px] font-bold text-slate-600 uppercase tracking-wider flex items-center gap-1.5">
<i class="fa-regular fa-calendar-days text-indigo-500 text-xs"></i> ${escapeHtml(dateLabel)}
</span>
<div class="h-px bg-slate-200 flex-1"></div>
<span class="text-[10px] text-slate-400 font-semibold">${items.length} срез.</span>
</div>
`;
html += items.map(s => {
const sid = s.snapshot_id || s.id;
const isFinal = Boolean(s.is_final) || sid.toUpperCase().includes('FINAL');
const badgeFinal = isFinal ? `<span class="ml-1.5 px-1.5 py-0.5 bg-amber-100 text-amber-800 rounded text-[9px] font-bold">Финал Y</span>` : '';
const cnt = s.record_count ?? s.count ?? s.records_count ?? 0;
const timeStr = s.snapshot_time || s.time || '—';
return `
<div class="p-3 bg-white rounded-xl border border-slate-200 hover:border-indigo-200 transition shadow-xs flex items-center justify-between group mb-2">
<div>
<div class="flex items-center">
<span class="text-xs font-bold font-mono text-slate-800">${escapeHtml(sid)}</span>
${badgeFinal}
</div>
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(timeStr)} · ${cnt} зап.</div>
</div>
<div class="flex items-center gap-1.5">
<button onclick="openSnapshotInspector('${escapeHtml(sid)}')" class="px-2.5 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition">Инспекция</button>
${!isFinal ? `
<button onclick="deleteSnapshotItem('${escapeHtml(sid)}')" class="w-6 h-6 flex items-center justify-center text-slate-300 hover:text-rose-500 rounded transition opacity-0 group-hover:opacity-100" title="Удалить срез"><i class="fa-regular fa-trash-can text-[11px]"></i></button>
` : ''}
</div>
</div>
`;
}).join('');
}
listContainer.innerHTML = html;
} catch (e) {
listContainer.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить срезы</div>`;
}
}
// 5. Ручное создание среза
async function createSnapshotManual() {
const dateVal = document.getElementById('manual-snapshot-date')?.value || '';
const timeVal = document.getElementById('manual-snapshot-time')?.value || '';
const btn = document.getElementById('btn-create-snapshot');
if (!dateVal) { alert('Укажите дату среза'); return; }
btn.disabled = true;
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-1.5"></i> Создание...`;
try {
const res = await fetch('/api/v1/snapshots/create', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ date_str: dateVal, time_str: timeVal })
});
if (res.ok) {
loadSnapshotsView();
} else {
const data = await res.json();
alert(`Ошибка: ${data.detail || 'Не удалось сформировать срез'}`);
}
} catch (e) {
alert('Ошибка сети');
} finally {
btn.disabled = false;
btn.innerHTML = `<i class="fa-solid fa-camera mr-1.5"></i> Сделать срез`;
}
}
// 6. Удаление среза
async function deleteSnapshotItem(snapshotId) {
if (!confirm(`Удалить срез ${snapshotId}?`)) return;
try {
const res = await fetch('/api/v1/snapshots', {
method: 'DELETE',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ snapshot_ids: [snapshotId] })
});
if (res.ok) loadSnapshotsView();
} catch (e) { alert('Ошибка сети'); }
}
// 7. Генерация отчетов On-Demand
window.generateReportDirect = async function(reportType) {
const fromInput = document.getElementById('snapshots-date-from');
const dateInput = fromInput ? isoToBackendFormat(fromInput.value) : '';
const labelMap = {
'SVODKA': 'Сводки',
'SIMPLIFIED': 'Упрощенного отчета',
'DETAILED': 'Детального отчета'
};
const targetLabel = labelMap[reportType] || 'отчета';
try {
const res = await fetch('/api/v1/reports/generate', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
report_type: reportType,
date: dateInput || null,
time: null // ⭐️ Всегда берем последний готовый срез
})
});
if (!res.ok) {
alert(`Ошибка сервера (${res.status}) при создании ${targetLabel}`);
return;
}
const data = await res.json();
const rep = data.reports?.[0];
if (rep && rep.status === 'success' && rep.download_url) {
window.open(rep.download_url, '_blank');
} else {
alert(`Ошибка: ${rep?.error || 'Не удалось сформировать файл'}`);
}
} catch (e) {
console.error('[Reports] Ошибка генерации:', e);
alert('Сетевая ошибка при запросе формирования отчета');
}
};
// 8. Инициализация при первичной загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
const todayIso = formatDateToISO(new Date());
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (fromInput && !fromInput.value) fromInput.value = todayIso;
if (toInput && !toInput.value) toInput.value = todayIso;
initManualSnapshotInputs();
});
// Экспортируем в глобальную область, чтобы core.js при переключении вкладок вызывал эту функцию
window.loadSnapshotsView = loadSnapshotsView;
window.createSnapshotManual = createSnapshotManual;
window.deleteSnapshotItem = deleteSnapshotItem;
@@ -0,0 +1,138 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/snapshot_inspector.js
* ROLE: Управление модальным окном полноразмерной инспекции срезов СКУД
* (фильтрация по ФИО, статусам, подразделениям и табличное представление).
* ===============================================================================
*/
let currentInspectorData = [];
let currentActiveSnapshotId = null;
async function openSnapshotInspector(snapshotId) {
currentActiveSnapshotId = snapshotId;
const modal = document.getElementById("snapshot-inspector-modal");
const titleEl = document.getElementById("inspector-modal-title");
const subEl = document.getElementById("inspector-modal-subtitle");
const tbody = document.getElementById("inspector-table-body");
if (!modal) return;
modal.classList.remove("hidden");
titleEl.innerText = `Инспекция среза #${snapshotId}`;
subEl.innerText = "Загрузка данных из базы...";
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-2"></i> Загрузка записей...</td></tr>`;
try {
const res = await fetch(`/api/v1/snapshots/${snapshotId}/details`, { headers: AuthManager.getAuthHeaders() });
if (res.ok) {
const data = await res.json();
currentInspectorData = data.records || [];
subEl.innerText = `Дата: ${data.date} · Всего записей: ${currentInspectorData.length}`;
populateDepartmentFilter(currentInspectorData);
renderInspectorTable(currentInspectorData);
} else {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-rose-500">Ошибка загрузки деталей среза</td></tr>`;
}
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-rose-500">Ошибка сети</td></tr>`;
}
}
function closeSnapshotInspectorModal() {
const modal = document.getElementById("snapshot-inspector-modal");
if (modal) modal.classList.add("hidden");
}
function populateDepartmentFilter(records) {
const select = document.getElementById("inspector-filter-dept");
if (!select) return;
const depts = [...new Set(records.map(r => r.department || r.Подразделение || "Без подразделения"))].sort();
select.innerHTML = `<option value="ALL">Все подразделения (${depts.length})</option>` +
depts.map(d => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join('');
}
function renderInspectorTable(records) {
const tbody = document.getElementById("inspector-table-body");
const countEl = document.getElementById("inspector-records-count");
if (!tbody) return;
if (records.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-slate-400">Нет записей, соответствующих фильтрам</td></tr>`;
if (countEl) countEl.innerText = "Показано записей: 0";
return;
}
tbody.innerHTML = records.map((r, idx) => {
const fio = r.fio || r.Сотрудник || "—";
const dept = r.department || r.Подразделение || "—";
const timeIn = r.time_in || r.Начало_дня || "Нет входа";
const firstAct = r.first_activity || r.Первая_активность || "—";
const timeOut = r.time_out || r.Конец_дня || "Нет выхода";
const duration = r.duration || r.Находился_в_здании || "00:00";
// ⭐️ Жестко определяем присутствие по факту наличия времени входа
const isPresent = timeIn && timeIn !== "Нет входа" && timeIn !== "—";
// Корректируем статус: если человек зашел, он точно присутствует
let status = r.status || r.Статус || "";
if (!status || status.includes("Отсутствовал") || status.includes("Нет событий")) {
status = isPresent ? "Присутствовал" : "Отсутствовал";
} else if (isPresent && !status.includes("Присутствовал")) {
status = "Присутствовал";
}
return `
<tr class="hover:bg-slate-50 transition">
<td class="p-3 text-slate-400 font-mono text-[11px]">${idx + 1}</td>
<td class="p-3 font-bold text-slate-800">${escapeHtml(fio)}</td>
<td class="p-3 text-slate-600">${escapeHtml(dept)}</td>
<td class="p-3 font-mono ${isPresent ? 'text-emerald-600 font-semibold' : 'text-slate-400'}">${escapeHtml(timeIn)}</td>
<td class="p-3 font-mono text-slate-500">${escapeHtml(firstAct)}</td>
<td class="p-3 font-mono text-slate-600">${escapeHtml(timeOut)}</td>
<td class="p-3 font-mono font-semibold text-slate-700">${escapeHtml(duration)}</td>
<td class="p-3">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${isPresent ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-slate-100 text-slate-500'}">
${escapeHtml(status)}
</span>
</td>
</tr>
`;
}).join('');
if (countEl) countEl.innerText = `Показано записей: ${records.length} из ${currentInspectorData.length}`;
}
function filterInspectorTable() {
const query = document.getElementById("inspector-search-input")?.value.toLowerCase() || "";
const statusFilter = document.getElementById("inspector-filter-status")?.value || "ALL";
const deptFilter = document.getElementById("inspector-filter-dept")?.value || "ALL";
const filtered = currentInspectorData.filter(r => {
const fio = (r.fio || r.Сотрудник || "").toLowerCase();
const dept = r.department || r.Подразделение || "Без подразделения";
const status = r.status || r.Статус || "";
const timeIn = r.time_in || r.Начало_дня || "Нет входа";
const isPresent = status.includes("Присутствовал") || timeIn !== "Нет входа";
const matchesQuery = fio.includes(query) || dept.toLowerCase().includes(query);
const matchesDept = deptFilter === "ALL" || dept === deptFilter;
let matchesStatus = true;
if (statusFilter === "PRESENT") matchesStatus = isPresent;
if (statusFilter === "ABSENT") matchesStatus = !isPresent;
return matchesQuery && matchesDept && matchesStatus;
});
renderInspectorTable(filtered);
}
window.downloadInspectorExport = function(format = 'xlsx') {
if (!currentActiveSnapshotId) {
alert('Срез не выбран');
return;
}
const cleanId = String(currentActiveSnapshotId).replace(/^#/, '').trim();
const url = `/api/v1/snapshots/${encodeURIComponent(cleanId)}/export?format=${format}`;
window.open(url, '_blank');
};
+10
View File
@@ -5,6 +5,16 @@
* ===============================================================================
*/
window.escapeHtml = window.escapeHtml || function (str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
let currentTasksFilter = 'ALL';
let tasksCache = [];
@@ -3,54 +3,57 @@
<div class="flex items-center justify-between">
<h3 id="manual-absence-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-location-dot text-indigo-600"></i>
<span>Добавление в реестр</span>
<span id="manual-absence-modal-title-text">Добавление в реестр</span>
</h3>
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark"></i>
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition">
<i class="fa-solid fa-xmark text-base"></i>
</button>
</div>
<div class="flex flex-col gap-3">
<form id="manual-absence-form" onsubmit="submitManualAbsence(event)" class="flex flex-col gap-3">
<input type="hidden" id="manual-absence-id" value="" />
<input type="hidden" id="manual-absence-dept" value="" />
<input type="hidden" id="manual-absence-pos" value="" />
<div class="relative">
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
<input type="text" id="manual-absence-fio-input" autocomplete="off" placeholder="Начните вводить фамилию..."
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" />
<input type="text" id="manual-absence-fio-input" autocomplete="off" required placeholder="Начните вводить фамилию..."
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800 transition" />
<div id="manual-absence-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
</div>
<input type="hidden" id="manual-absence-dept" />
<input type="hidden" id="manual-absence-pos" />
<div id="manual-absence-reason-block" class="hidden">
<label class="block text-[11px] font-bold text-slate-600 mb-1">Причина отсутствия:</label>
<select id="manual-absence-reason-select" class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50"></select>
<select id="manual-absence-reason-select" class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800"></select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Начало:</label>
<input type="date" id="manual-absence-start-date"
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
<input type="date" id="manual-absence-start-date" required
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = сегодня</span>
<span class="text-[10px] text-slate-400 mt-0.5 block">Дата выхода/отбытия</span>
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
<input type="date" id="manual-absence-end-date"
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
<input type="date" id="manual-absence-end-date" required
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">По умолчанию: сегодня</span>
<span class="text-[10px] text-slate-400 mt-0.5 block">Включительно</span>
</div>
</div>
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
<div id="manual-absence-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<div class="flex items-center justify-end gap-2 mt-2 pt-3 border-t border-slate-100">
<button type="button" onclick="closeManualAbsenceModal()"
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
Отмена
</button>
<button type="button" onclick="submitManualAbsence()"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">
<button type="submit" id="manual-absence-submit-btn"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow-xs transition">
Сохранить
</button>
</div>
</div>
</form>
</div>
</div>
@@ -0,0 +1,101 @@
<div id="presence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl h-[88vh] flex flex-col overflow-hidden border border-slate-200">
<!-- Шапка -->
<div class="px-6 py-4 border-b border-slate-200 bg-slate-50/80 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 text-emerald-600 flex items-center justify-center font-bold text-xl">
🏢
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">Оперативный мониторинг присутствия</h3>
<p id="presence-modal-subtitle" class="text-xs text-slate-500">Загрузка данных...</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button id="btn-presence-force-refresh" class="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-semibold flex items-center space-x-1.5 transition-colors shadow-sm">
<span>🔄</span>
<span>Запросить из СКУД</span>
</button>
<button id="btn-close-presence-modal" class="text-slate-400 hover:text-slate-600 p-2 rounded-lg hover:bg-slate-100 transition-colors">
✕
</button>
</div>
</div>
<!-- Метрики (плашки) - 7 колонок для точного баланса штата 1С -->
<div class="grid grid-cols-2 sm:grid-cols-7 gap-2 p-4 bg-slate-100/60 border-b border-slate-200">
<div class="bg-white p-2.5 rounded-xl border border-slate-200/80 text-center">
<div class="text-[11px] text-slate-500 font-medium">Штат 1С</div>
<div id="metric-total" class="text-lg font-bold text-slate-800">—</div>
</div>
<div class="bg-emerald-50 p-2.5 rounded-xl border border-emerald-200/80 text-center">
<div class="text-[11px] text-emerald-700 font-medium">В здании</div>
<div id="metric-inside" class="text-lg font-bold text-emerald-700">—</div>
</div>
<div class="bg-amber-50 p-2.5 rounded-xl border border-amber-200/80 text-center">
<div class="text-[11px] text-amber-700 font-medium">Вышли</div>
<div id="metric-outside" class="text-lg font-bold text-amber-700">—</div>
</div>
<div class="bg-blue-50 p-2.5 rounded-xl border border-blue-200/80 text-center">
<div class="text-[11px] text-blue-700 font-medium">Удаленка</div>
<div id="metric-remote" class="text-lg font-bold text-blue-700">—</div>
</div>
<div class="bg-purple-50 p-2.5 rounded-xl border border-purple-200/80 text-center">
<div class="text-[11px] text-purple-700 font-medium">Отпуск/Ком.</div>
<div id="metric-absence" class="text-lg font-bold text-purple-700">—</div>
</div>
<div class="bg-rose-50 p-2.5 rounded-xl border border-rose-200/80 text-center">
<div class="text-[11px] text-rose-700 font-medium">Не пришли</div>
<div id="metric-not-entered" class="text-lg font-bold text-rose-700">—</div>
</div>
<div class="bg-slate-200/70 p-2.5 rounded-xl border border-slate-300 text-center">
<div class="text-[11px] text-slate-600 font-medium">Исключения</div>
<div id="metric-excluded" class="text-lg font-bold text-slate-700">—</div>
</div>
</div>
<!-- Панель управления и фильтрации -->
<div class="p-4 border-b border-slate-200 flex flex-wrap items-center justify-between gap-3 bg-white">
<!-- Табы статусов -->
<div class="flex items-center space-x-1 overflow-x-auto bg-slate-100 p-1 rounded-xl text-xs font-semibold">
<button class="presence-tab-btn px-3 py-1.5 rounded-lg bg-white shadow-xs text-slate-800" data-tab="ALL">Все (<span id="tab-cnt-all">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="INSIDE">В здании (<span id="tab-cnt-inside">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="OUTSIDE">Вышли (<span id="tab-cnt-outside">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="REMOTE">Удаленка (<span id="tab-cnt-remote">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="ABSENCE">Отсутствуют (<span id="tab-cnt-absence">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="NOT_ENTERED">Не пришли (<span id="tab-cnt-not-entered">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="EXCLUDED">Исключения (<span id="tab-cnt-excluded">0</span>)</button>
</div>
<!-- Поиск по ФИО и фильтр отдела -->
<div class="flex items-center space-x-2 w-full sm:w-auto">
<input type="text" id="presence-search-input" placeholder="🔍 Поиск сотрудника..." class="text-xs px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg w-48 focus:outline-emerald-500 focus:bg-white">
<select id="presence-dept-filter" class="text-xs px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg max-w-[180px] focus:outline-emerald-500">
<option value="ALL">Все подразделения</option>
</select>
</div>
</div>
<!-- Таблица сотрудников -->
<div class="flex-1 overflow-y-auto p-4">
<table class="w-full text-left text-xs border-collapse">
<thead class="sticky top-0 bg-slate-50 border-b border-slate-200 text-slate-500 uppercase tracking-wider">
<tr>
<th class="py-2.5 px-3 font-semibold">Сотрудник</th>
<th class="py-2.5 px-3 font-semibold">Отдел</th>
<th class="py-2.5 px-3 font-semibold">Должность</th>
<th class="py-2.5 px-3 font-semibold text-center">Статус</th>
<th class="py-2.5 px-3 font-semibold text-center">Отметка</th>
</tr>
</thead>
<tbody id="presence-table-body" class="divide-y divide-slate-100">
<tr>
<td colspan="5" class="py-8 text-center text-slate-400">Загрузка данных...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -13,10 +13,11 @@
<form id="remote-worker-form" onsubmit="handleRemoteWorkerSubmit(event)" class="flex flex-col gap-3">
<input type="hidden" id="rw-mode" value="ADD" />
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
<input type="text" id="rw-fio" required placeholder="Например: Иванов Иван Иванович"
<div class="relative">
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
<input type="text" id="rw-fio" required autocomplete="off" placeholder="Начните вводить фамилию..."
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
<div id="rw-fio-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
</div>
<div>
@@ -0,0 +1,94 @@
<div id="snapshot-inspector-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-6xl h-[90vh] flex flex-col overflow-hidden">
<!-- ШАПКА МОДАЛКИ ИНСПЕКЦИИ СРЕЗА -->
<div class="px-6 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-indigo-600 text-white flex items-center justify-center shadow-xs">
<i class="fa-solid fa-magnifying-glass-chart text-sm"></i>
</div>
<div>
<h3 id="inspector-modal-title" class="text-sm font-bold text-slate-800">Инспекция среза</h3>
<p id="inspector-modal-subtitle" class="text-[11px] text-slate-400">Загрузка данных...</p>
</div>
</div>
<!-- КНОПКИ ЭКСПОРТА И ЗАКРЫТИЯ -->
<div class="flex items-center gap-2">
<button onclick="downloadInspectorExport('xlsx')"
class="px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 shadow-xs"
title="Скачать срез в формате Excel (.xlsx)">
<i class="fa-solid fa-file-excel text-emerald-600"></i>
<span>Excel</span>
</button>
<button onclick="downloadInspectorExport('csv')"
class="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-200 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 shadow-xs"
title="Скачать срез в CSV (для анализа нейросетями)">
<i class="fa-solid fa-file-csv text-slate-600"></i>
<span>CSV</span>
</button>
<button onclick="closeSnapshotInspectorModal()"
class="text-slate-400 hover:text-slate-600 p-2 rounded-lg hover:bg-slate-200 transition ml-1"
title="Закрыть">
<i class="fa-solid fa-xmark text-base"></i>
</button>
</div>
</div>
<!-- ПАНЕЛЬ ФИЛЬТРОВ И ПОИСКА -->
<div class="px-6 py-3 bg-white border-b border-slate-200 flex flex-wrap items-center gap-3 shrink-0">
<div class="flex-1 min-w-[240px] relative">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-2.5 text-slate-400 text-xs"></i>
<input type="text" id="inspector-search-input" oninput="filterInspectorTable()" placeholder="Поиск по ФИО или должности..."
class="w-full text-xs pl-8 pr-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
</div>
<div class="flex items-center gap-2">
<select id="inspector-filter-status" onchange="filterInspectorTable()" class="text-xs px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500">
<option value="ALL">Все статусы</option>
<option value="PRESENT">Присутствовал</option>
<option value="ABSENT">Отсутствовал</option>
<option value="ANOMALY">Аномалии</option>
</select>
<select id="inspector-filter-dept" onchange="filterInspectorTable()" class="text-xs px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500">
<option value="ALL">Все подразделения</option>
</select>
</div>
</div>
<!-- ТАБЛИЦА ДАННЫХ (СКРОЛЛИРУЕМАЯ ОБЛАСТЬ НА ВСЮ ВЫСОТУ) -->
<div class="flex-1 overflow-y-auto p-6 bg-slate-50">
<div class="bg-white border border-slate-200 rounded-xl shadow-xs overflow-hidden">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-100 border-b border-slate-200 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
<th class="p-3">#</th>
<th class="p-3">Сотрудник</th>
<th class="p-3">Подразделение</th>
<th class="p-3">Вход</th>
<th class="p-3">Первая акт.</th>
<th class="p-3">Выход</th>
<th class="p-3">В здании</th>
<th class="p-3">Статус</th>
</tr>
</thead>
<tbody id="inspector-table-body" class="divide-y divide-slate-100 text-xs text-slate-700">
<tr><td colspan="8" class="text-center py-8 text-slate-400">Выберите срез для инспекции</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ПОДВАЛ МОДАЛКИ -->
<div class="px-6 py-3 bg-white border-t border-slate-200 flex items-center justify-between shrink-0">
<span id="inspector-records-count" class="text-xs font-semibold text-slate-500">Записей не найдено</span>
<button onclick="closeSnapshotInspectorModal()" class="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-xs font-bold transition">
Закрыть
</button>
</div>
</div>
</div>
-12
View File
@@ -1,12 +0,0 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "[CRON HOURLY SNAPSHOT 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 SNAPSHOT FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
+5 -3
View File
@@ -395,7 +395,6 @@ def purge_chat_context(session_id=None, purge_all=False):
print(f"\n[✓] {mode} зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
# ⭐️ Новые функции управления исключениями (Exceptions & Whitelist)
def print_exceptions():
"""Выводит реестр исключений и белый список сотрудников из базы SQLite."""
exc = get_all_exceptions_from_db()
@@ -498,7 +497,11 @@ def main():
], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Действие ('del', 'purge', 'add', 'sync') или дата/сессия")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, имя файла)")
parser.add_argument('-c', '--category', type=str, default=None, choices=['departments', 'positions', 'fio', 'position_keywords', 'include_fio', 'turnstile_fio', 'turnstile_departments'], help="Категория исключения")
parser.add_argument('-c', '--category', type=str, default=None, choices=[
'departments', 'positions', 'fio', 'position_keywords',
'include_fio', 'turnstile_fio', 'turnstile_departments',
'fligel_fio', 'fligel_departments'
], help="Категория исключения")
parser.add_argument('-v', '--value', type=str, default=None, help="Значение исключения (ФИО, отдел, должность)")
parser.add_argument('-m', '--comment', type=str, default="", help="Комментарий к исключению")
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
@@ -535,7 +538,6 @@ def main():
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':
+62 -54
View File
@@ -2,81 +2,89 @@
"""
===============================================================================
FILE: scripts/diagnostics/make_web_snapshot.py
ROLE: Генерация актуального слепка Web API, фронтенда (HTML/JS) и LLM-движка.
ROLE: Динамическая автоматическая генерация слепка Web API, фронтенда и LLM.
Исключает временный кэш, авто-обнаруживает новые модули sidebar и роутеры.
===============================================================================
"""
import os
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
WEB_API_DIR = os.path.join(ROOT_DIR, "modules", "web_api")
OUTPUT_FILE = os.path.join(ROOT_DIR, "web_api_code_snapshot.md")
WEB_TARGET_FILES = [
# Главная точка входа и роутеры
"modules/web_api/main.py",
"modules/web_api/routers/chat.py",
"modules/web_api/routers/remote_workers.py",
"modules/web_api/routers/manual_absences.py",
"modules/web_api/routers/exceptions.py",
"modules/web_api/routers/snapshots.py",
"modules/web_api/routers/tasks.py",
"modules/web_api/routers/auth.py",
"modules/web_api/routers/admin.py",
"modules/web_api/routers/files.py",
"modules/web_api/routers/context.py",
ALLOWED_EXTENSIONS = {
".py": "py",
".js": "js",
".html": "html",
".css": "css",
".json": "json"
}
# LLM ядро
"modules/web_api/llm/agent.py",
"modules/web_api/llm/schemas.py",
"modules/web_api/llm/db_tools.py",
"modules/web_api/llm/core/fast_path.py",
"modules/web_api/llm/core/context_manager.py",
"modules/web_api/llm/core/ollama_client.py",
"modules/web_api/llm/core/tool_injector.py",
IGNORE_DIRS = {
"__pycache__", ".git", "venv", ".venv", "uploads", "logs",
"node_modules", ".idea", ".vscode"
}
# Фронтенд (Разметка и клиентские скрипты)
"modules/web_api/static/index.html",
"modules/web_api/static/js/sidebar.js",
"modules/web_api/static/js/tasks.js",
"modules/web_api/static/js/manual_absences.js",
"modules/web_api/static/js/chat/core.js",
"modules/web_api/static/js/chat/task_widget.js",
"modules/web_api/static/js/auth.js",
"modules/web_api/static/js/app.js"
]
IGNORE_FILES = {
"web_api_code_snapshot.md",
"favicon.ico"
}
def collect_web_files():
"""Рекурсивно собирает все исходники web_api без необходимости хардкода."""
target_files = []
if not os.path.exists(WEB_API_DIR):
print(f"[❌] Директория не найдена: {WEB_API_DIR}")
return []
for root, dirs, files in os.walk(WEB_API_DIR):
# Исключаем служебные папки
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
for file in sorted(files):
if file in IGNORE_FILES or file.startswith("."):
continue
_, ext = os.path.splitext(file)
if ext.lower() in ALLOWED_EXTENSIONS:
full_path = os.path.join(root, file)
# Пропускаем пустые __init__.py (0 байт)
if file == "__init__.py" and os.path.getsize(full_path) == 0:
continue
rel_path = os.path.relpath(full_path, ROOT_DIR)
target_files.append(rel_path)
return sorted(target_files)
def create_web_snapshot():
content = ["# 🌐 WEB API & FRONTEND CODE SNAPSHOT\n"]
files_to_pack = collect_web_files()
content = ["# 🌐 WEB API & FRONTEND CODE SNAPSHOT (AUTO-DISCOVERY)\n"]
included_count = 0
for rel_path in WEB_TARGET_FILES:
for rel_path in files_to_pack:
full_path = os.path.join(ROOT_DIR, rel_path)
if os.path.exists(full_path):
ext = os.path.splitext(rel_path)[1].replace(".", "")
lang_map = {
"js": "js",
"py": "py",
"html": "html",
"css": "css",
"json": "json"
}
lang = lang_map.get(ext, "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
else:
print(f"[ℹ️] Пропущен отсутствующий файл: {rel_path}")
ext = os.path.splitext(rel_path)[1].lower()
lang = ALLOWED_EXTENSIONS.get(ext, "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(content))
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
print(f"\n[✓] Web API + Фронтенд слепок создан: {OUTPUT_FILE}")
print(f"\n[✓] Web API + Фронтенд слепок успешно создан: {OUTPUT_FILE}")
print(f" Включено файлов: {included_count} | Размер: {size_kb:.1f} KB\n")
+5 -3
View File
@@ -36,7 +36,9 @@ def get_all_exceptions_from_db() -> Dict[str, List[str]]:
"position_keywords": [],
"include_fio": [],
"turnstile_fio": [],
"turnstile_departments": []
"turnstile_departments": [],
"fligel_fio": [], # ⭐️ Сотрудники с доступом через Флигель (DoorIndex = 23)
"fligel_departments": [] # ⭐️ Отделы с доступом через Флигель
}
with get_connection() as conn:
@@ -56,8 +58,8 @@ def get_all_exceptions_from_db() -> Dict[str, List[str]]:
def add_exception_to_db(category: str, value: str, comment: str = "") -> bool:
init_exceptions_table()
# ФИО очищаем и приводим к нормализованному виду
val_clean = normalize_fio(value) if category in ["fio", "include_fio", "turnstile_fio"] else value.strip()
# Нормализуем ФИО в том числе для реестра флигеля
val_clean = normalize_fio(value) if category in ["fio", "include_fio", "turnstile_fio", "fligel_fio"] else value.strip()
if not val_clean:
return False
with get_connection() as conn:
+22 -14
View File
@@ -176,23 +176,31 @@ def delete_manual_absence(item_id: int) -> bool:
def get_manual_absences_list(absence_type: Optional[str] = None) -> List[Dict[str, Any]]:
init_manual_absences_table()
today_str = datetime.now().strftime("%Y-%m-%d")
# ⭐️ Обязательно row_factory=True для преобразования строк SQLite в dict
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
# 1. Автоочистка просроченных командировок и отсутствий
cursor.execute("""
DELETE FROM manual_absences
WHERE date_end IS NOT NULL
AND date_end != ''
AND date_end < ?
""", (today_str,))
conn.commit()
# 2. Выборка активных записей
query = "SELECT id, absence_type, fio, department, position, reason, date_start, date_end, comment FROM manual_absences"
params = []
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()]
query += " WHERE absence_type = ?"
params.append(absence_type)
query += " ORDER BY id DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
return [dict(r) for r in rows]
def get_active_manual_absences_for_date(date_str: str) -> List[Dict[str, Any]]:
+256
View File
@@ -0,0 +1,256 @@
"""
===============================================================================
FILE: services/presence_service.py
ROLE: Сервис оперативного мониторинга («Кто в здании прямо сейчас»).
1. Строгое соответствие общему штату 1С (286 чел.).
2. Служебный персонал (уборщики, контролеры) маркируется как EXCLUDED
и не искажает вкладки "Не пришли" и "В здании".
3. Сотрудники флигеля/двора (Чупряев, Пухаренко, БЛ) при выходе через
турникет во двор остаются со статусом "В здании (Флигель/Двор)".
===============================================================================
"""
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
import pandas as pd
from core.connection import get_connection
from config import DATE_TODAY, normalize_fio
from services.exceptions_repo import get_all_exceptions_from_db
from services.data_loader import load_1c_data_smart
from services.scud_export import run_export
logger = logging.getLogger("PRESENCE_SERVICE")
def get_latest_zup_staff() -> Optional[pd.DataFrame]:
"""Загружает самый свежий доступный срез штата из zup_staff."""
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
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()
if not row:
return None
latest_date = row[0]
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(latest_date,)
)
return df if not df.empty else None
def get_live_presence(date_str: Optional[str] = None, force_refresh: bool = False) -> Dict[str, Any]:
clean_date = (date_str or DATE_TODAY).replace('_', '.')
data_source = "LOCAL_SQLITE"
# 1. Принудительный опрос MS SQL при запросе
if force_refresh:
logger.info(f"[Presence] Прямой опрос MS SQL Орион за {clean_date}...")
try:
run_export(input_date=clean_date, save_xlsx=False, debug=False)
data_source = "LIVE_MSSQL"
except Exception as e:
logger.error(f"[Presence] Ошибка при обращении к MS SQL: {e}")
# 2. Выборка последних событий по каждому сотруднику за сегодня
events_raw = []
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
query = """
WITH RankedEvents AS (
SELECT
hoz_organ,
fio,
fio_clean,
department,
time_val,
direction,
ROW_NUMBER() OVER (
PARTITION BY fio_clean
ORDER BY time_val DESC, id DESC
) as rn
FROM scud_events_raw
WHERE log_date = ?
)
SELECT hoz_organ, fio, fio_clean, department, time_val, direction
FROM RankedEvents
WHERE rn = 1;
"""
cursor.execute(query, (clean_date,))
events_raw = [dict(r) for r in cursor.fetchall()]
last_events_map = {r['fio_clean']: r for r in events_raw}
# 3. Штат 1С и кадровые отклонения
df_staff, df_abs = load_1c_data_smart(clean_date, use_db=True)
if df_staff is None or df_staff.empty:
df_staff = get_latest_zup_staff()
absences_map = {}
if df_abs is not None and not df_abs.empty:
for _, row in df_abs.iterrows():
fc = row.get('fio_clean')
reason = str(row.get('Вид_отсутствия', '')).strip()
if fc and reason:
absences_map[fc] = reason
# 4. Исключения и реестр флигеля из базы данных
try:
exceptions_cfg = get_all_exceptions_from_db()
except Exception:
exceptions_cfg = {}
exc_fios = set([normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f])
exc_depts = set([str(d).strip().lower() for d in exceptions_cfg.get("departments", []) if d])
exc_positions = set([str(p).strip().lower() for p in exceptions_cfg.get("positions", []) if p])
# Реестр флигеля / двора (ФИО и отделы)
fligel_fios = set([normalize_fio(f) for f in exceptions_cfg.get("fligel_fio", []) if f])
fligel_depts = set([str(d).strip().lower() for d in exceptions_cfg.get("fligel_departments", []) if d])
# Авто-добавление ключевых сотрудников и подразделений лаборатории БЛ
fligel_depts.update(["бл", "бетонная лаборатория", "испытательная геотехническая лаборатория"])
fligel_fios.update([normalize_fio("Чупряев Антон Михайлович"), normalize_fio("Пухаренко Ольга Юрьевна")])
# Ключевые слова технического персонала (клининг, контролеры КПП)
default_exc_keywords = ["уборщ", "дворник", "контролер", "внутреннего контроля", "клининг"]
def check_is_excluded(fio_c: str, dept: str, pos: str) -> bool:
if fio_c in exc_fios:
return True
d_lower = str(dept).strip().lower()
if d_lower in exc_depts or any(k in d_lower for k in ["контрол", "клининг"]):
return True
p_lower = str(pos).strip().lower()
if p_lower in exc_positions or any(k in p_lower for k in default_exc_keywords):
return True
return False
def check_is_fligel(fio_c: str, dept: str) -> bool:
if fio_c in fligel_fios:
return True
d_lower = str(dept).strip().lower()
return d_lower in fligel_depts or any(k in d_lower for k in ["бетонная лаб", "геотехническая лаб"])
# 5. Формирование полного списка сотрудников
staff_items = []
seen_fios = set()
# 5.1. Обработка всех сотрудников из официального штата 1С
if df_staff is not None and not df_staff.empty:
for _, s_row in df_staff.iterrows():
fio_raw = s_row.get('ФИО', '')
fio_c = s_row.get('fio_clean', normalize_fio(fio_raw))
if not fio_c or fio_c in seen_fios:
continue
seen_fios.add(fio_c)
dept_1c = str(s_row.get('Подразделение', '—')).strip()
pos = str(s_row.get('Должность', '—')).strip()
is_excluded = check_is_excluded(fio_c, dept_1c, pos)
is_fligel = check_is_fligel(fio_c, dept_1c)
absence_reason = absences_map.get(fio_c, "")
event = last_events_map.get(fio_c)
has_events_today = event is not None
last_time = event['time_val'].split()[-1][:5] if event and ' ' in event['time_val'] else (event['time_val'][:5] if event else '—')
direction = str(event.get('direction', '')).upper() if event else ""
dept_val = str(event['department']).strip() if (event and event.get('department') and str(event['department']).strip() not in ['—', 'Без подразделения', '']) else dept_1c
is_remote = "удален" in absence_reason.lower() or "дистанцион" in absence_reason.lower()
is_trip_or_leave = bool(absence_reason) and not is_remote
# ⭐️ ЛОГИКА ОПРЕДЕЛЕНИЯ СТАТУСА:
if is_excluded:
# Сотрудник входит в штат 1С, но имеет служебный статус исключения
status = "EXCLUDED"
status_label = "Исключение (Служебный)"
elif is_fligel and has_events_today:
# ⭐️ Сотрудник флигеля/двора: выход через турникет = внутридневной выход во двор на рабочее место
status = "INSIDE"
status_label = "В здании (Флигель/Двор)"
elif direction == "OUT":
status = "OUTSIDE"
status_label = "Вышел"
elif direction == "IN" or has_events_today:
status = "INSIDE"
status_label = "В здании"
elif is_remote:
status = "REMOTE"
status_label = "Удаленная работа"
elif is_trip_or_leave:
status = "OFFICIAL_ABSENCE"
status_label = absence_reason
else:
status = "NOT_ENTERED"
status_label = "Не пришел"
staff_items.append({
"fio": fio_raw,
"fio_clean": fio_c,
"department": dept_val,
"position": pos,
"status": status,
"status_label": status_label,
"last_time": last_time,
"last_direction": direction or "NONE",
"absence_reason": absence_reason,
"is_excluded": is_excluded,
"is_fligel": is_fligel
})
# Сортировка: В здании -> Вышли -> Удаленка -> Отсутствуют -> Не пришли -> Исключения
status_order = {
"INSIDE": 0,
"OUTSIDE": 1,
"REMOTE": 2,
"OFFICIAL_ABSENCE": 3,
"NOT_ENTERED": 4,
"EXCLUDED": 5
}
staff_items.sort(key=lambda x: (status_order.get(x["status"], 6), x["fio"].lower()))
# 6. Метрики
total_staff = len(staff_items)
inside_count = sum(1 for x in staff_items if x["status"] == "INSIDE")
outside_count = sum(1 for x in staff_items if x["status"] == "OUTSIDE")
remote_count = sum(1 for x in staff_items if x["status"] == "REMOTE")
absence_count = sum(1 for x in staff_items if x["status"] == "OFFICIAL_ABSENCE")
not_entered_count = sum(1 for x in staff_items if x["status"] == "NOT_ENTERED")
excluded_count = sum(1 for x in staff_items if x["status"] == "EXCLUDED")
latest_event_time = "—"
if events_raw:
times = [r['time_val'] for r in events_raw if r.get('time_val')]
if times:
max_t = max(times)
latest_event_time = max_t.split()[-1][:5] if ' ' in max_t else max_t[:5]
return {
"date": clean_date,
"data_source": data_source,
"latest_event_time": latest_event_time,
"timestamp": datetime.now().strftime("%H:%M:%S"),
"metrics": {
"total_staff": total_staff,
"inside": inside_count,
"outside": outside_count,
"remote": remote_count,
"official_absence": absence_count,
"not_entered": not_entered_count,
"excluded": excluded_count
},
"records": staff_items
}
+39 -4
View File
@@ -1,6 +1,7 @@
"""
===============================================================================
FILE: services/scud_etl/pipeline.py
ROLE: Загрузка наилучших срезов СКУД и штата/отсутствий 1С с умным fallback-ом.
===============================================================================
"""
@@ -12,7 +13,7 @@ import pandas as pd
from core.connection import get_connection
from core.database import load_scud_from_db_by_snapshot
from config import DATA_DIR
from services.data_loader import load_staff_data, load_absent_data
from services.data_loader import load_1c_data_smart, load_staff_data, load_absent_data
logger = logging.getLogger("SCUD_PIPELINE")
@@ -57,8 +58,42 @@ def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) ->
def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
"""
Загружает реестры штата и отсутствий 1С на указанную дату через data_loader.
Загружает штат и отсутствия с каскадным fallback:
1. Синхронизирует свежие файлы с сетевой шары.
2. Загружает штат и отсутствия через load_1c_data_smart(..., use_db=True).
3. Если штат пуст — берет последний доступный срез штата из zup_staff в SQLite.
"""
df_staff = load_staff_data(date_str)
df_abs = load_absent_data(date_str)
clean_date = date_str.replace('_', '.')
try:
from services.share_copier import copy_1c_files_from_share
copy_1c_files_from_share()
except Exception as e:
logger.warning(f"[Pipeline] Ошибка копирования с шары: {e}")
df_staff, df_abs = load_1c_data_smart(clean_date, use_db=True)
# Fallback за штат: ако данашњи штат још увек није доступан, користи се претходни из базе
if df_staff is None or df_staff.empty:
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
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()
if row and row[0]:
fallback_date = row[0]
df_staff = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(fallback_date,)
)
logger.info(f"[Pipeline] Для даты {clean_date} применен штат за {fallback_date} ({len(df_staff)} чел.)")
return df_staff, df_abs
+20 -1
View File
@@ -5,7 +5,7 @@ ROLE: Изолированные SQL-шаблоны для MS SQL Server (СКУ
===============================================================================
"""
# 1. Основной срез СКУД с учетом двухконтурного правого турникета
# 1. Основной срез СКУД с учетом турникетов и флигеля
SQL_QUERY_TEMPLATE = r"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @TargetDate DATE = @InputDate;
@@ -40,6 +40,12 @@ WITH PercoPassages AS (
log.DoorIndex = 2
AND ({turnstile_filter_sql})
)
OR
-- Контур 3: Флигель 1 эт. разрешен только для реестра флигеля
(
log.DoorIndex = 23
AND ({fligel_filter_sql})
)
)
),
Passages AS (
@@ -158,10 +164,23 @@ SELECT
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
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.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
AND p.DateTimeInArchive IS NULL
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
AND p.Name NOT LIKE N'бр.%'
AND p.Name NOT LIKE N'Гость%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
ORDER BY log.TimeVal ASC;
"""
+27 -18
View File
@@ -12,7 +12,7 @@ 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.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.snapshots.finder import find_or_create_snapshot_for_time
@@ -27,37 +27,46 @@ def generate_svodka_service(
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует оперативную сводку на указанную дату / время:
- target_date: дата сводки (по умолчанию сегодня).
- target_time: время среза (например '14:30').
- snapshot_id: точный ID среза.
Формирует оперативную сводку:
- По умолчанию берет ПОСЛЕДНИЙ готовый снапшот из базы SQLite (без долгого опроса MS SQL).
"""
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
# 1. Загрузка среза СКУД строго из готовых в базе
if snapshot_id:
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
elif target_time:
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=False)
applied_note = note
if note:
logger.info(note)
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=found_id)
else:
# По умолчанию: берем самый свежий существующий срез за дату
df_scud = load_best_snapshot_for_date(date_clean, prefer_final_y=False)
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 'последний доступный'}) не найден в базе."
"message": f"Срез СКУД за {date_clean} не найден в базе данных."
}
# Извлекаем время фактического среза для имени файла
actual_snap_time = ""
if 'snapshot_time' in df_scud.columns and not df_scud.empty:
raw_st = str(df_scud['snapshot_time'].iloc[0]).strip()
if " " in raw_st:
actual_snap_time = raw_st.split()[1][:5].replace(':', '-')
# 2. Загружаем актуальные кадровые данные 1С (штат + отсутствия + реестры)
df_staff, df_abs = load_1c_files_for_date(date_clean)
# 3. Слияние и расчет
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 ""
# 4. Формирование книги Excel
time_suffix = f" на {actual_snap_time}" if actual_snap_time else ""
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
@@ -68,13 +77,13 @@ def generate_svodka_service(
"status": "success",
"report_type": "SVODKA",
"date": date_clean,
"target_time": target_time,
"snapshot_id": snapshot_id or "AUTO_LATEST",
"target_time": actual_snap_time,
"snapshot_id": snapshot_id or "LATEST_READY",
"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 ''} успешно сформирована."
"message": f"Ежедневная сводка на {date_clean} успешно сформирована."
}
+69 -12
View File
@@ -3,7 +3,8 @@
FILE: services/scud_export.py
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
Корректная фильтрация транзитных проходов турникетов парковки и двора.
Учет только левого PERCo (DoorIndex = 1) и факта физического прохода (Event = 32).
Учет только левого PERCo (DoorIndex = 1), правого PERCo (DoorIndex = 2)
и входа через Флигель (DoorIndex = 23).
===============================================================================
"""
@@ -109,6 +110,12 @@ WITH PercoPassages AS (
log.DoorIndex = 2
AND ({turnstile_filter_sql})
)
OR
-- Контур 3: Флигель 1 эт. разрешен только для реестра флигеля
(
log.DoorIndex = 23
AND ({fligel_filter_sql})
)
)
),
Passages AS (
@@ -226,11 +233,24 @@ SELECT
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
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.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
AND p.DateTimeInArchive IS NULL
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
AND p.Name NOT LIKE N'бр.%'
AND p.Name NOT LIKE N'Гость%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
ORDER BY log.TimeVal ASC;
"""
@@ -313,6 +333,26 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
os.makedirs(SCUD_DIR, exist_ok=True)
# ⭐️ 1. ПОЛУЧАСОВАЯ СИНХРОНИЗАЦИЯ 1С:ЗУП (Файлы с шары + База данных)
try:
log("[1C] Проверка сетевой шары и синхронизация файлов 1С...")
copy_1c_files_from_share()
target_sync_date = input_date or datetime.now().strftime("%d.%m.%Y")
# Прямой опрос свежих отсутствий и сохранение в SQLite
df_fresh_abs = load_absent_data(target_sync_date)
if df_fresh_abs is not None and not df_fresh_abs.empty:
save_absences_to_db(df_fresh_abs, target_sync_date)
log(f"[1C] [✓] Актуализировано {len(df_fresh_abs)} отсутствий в SQLite за {target_sync_date}", "SUCCESS")
# Если на шаре появился свежий штат — также обновляем его в базе
df_fresh_staff = load_staff_data(target_sync_date)
if df_fresh_staff is not None and not df_fresh_staff.empty:
save_staff_to_db(df_fresh_staff, target_sync_date)
log(f"[1C] [✓] Актуализирован штат ({len(df_fresh_staff)} чел.) в SQLite за {target_sync_date}", "SUCCESS")
except Exception as e:
log(f"[1C] ⚠️ Ошибка фоновой синхронизации 1С: {e}", "WARNING")
targets = get_targets(input_date, input_time)
conn_str = (
f"DRIVER={{{ODBC_DRIVER}}};"
@@ -338,22 +378,23 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
log(f"[ℹ️] День ({processing_date_str}) уже зафиксирован финишным снапшотом _FINAL. Пропускаем.")
continue
# 1. Формируем фильтр правого турникета из exceptions_registry
exc_data = load_exceptions()
# 1. Формируем фильтр правого турникета (DoorIndex = 2)
t_fios = [f.replace("'", "''") for f in exc_data.get('turnstile_fio', []) if f]
t_depts = [d.replace("'", "''") for d in exc_data.get('turnstile_departments', []) if d]
full_fio_sql = (
"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"
"))"
)
conditions = []
if t_fios:
fio_in = ", ".join([f"N'{f}'" for f in t_fios])
# Склеиваем Фамилию + Имя + Отчество для точного сравнения с реестром ФИО
full_fio_sql = (
"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"
"))"
)
conditions.append(f"{full_fio_sql} IN ({fio_in})")
if t_depts:
@@ -362,7 +403,22 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
turnstile_filter_sql = " OR ".join(conditions) if conditions else "1 = 0"
# 2. Безопасное математическое определение @EndDate через DATEADD (независимо от локали сервера)
# 1.1 Формируем фильтр Флигеля (DoorIndex = 23)
fl_fios = [f.replace("'", "''") for f in exc_data.get('fligel_fio', []) if f]
fl_depts = [d.replace("'", "''") for d in exc_data.get('fligel_departments', []) if d]
fl_conditions = []
if fl_fios:
fl_fio_in = ", ".join([f"N'{f}'" for f in fl_fios])
fl_conditions.append(f"{full_fio_sql} IN ({fl_fio_in})")
if fl_depts:
fl_dept_in = ", ".join([f"N'{d}'" for d in fl_depts])
fl_conditions.append(f"ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') IN ({fl_dept_in})")
fligel_filter_sql = " OR ".join(fl_conditions) if fl_conditions else "1 = 0"
# 2. Безопасное математическое определение @EndDate через DATEADD
if target_time:
t_clean = target_time.strip()
t_parts = t_clean.split(":")
@@ -387,7 +443,8 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
sql_query = SQL_QUERY_TEMPLATE.format(
target_date=processing_date.strftime("%Y-%m-%d"),
end_datetime_sql=end_datetime_sql,
turnstile_filter_sql=turnstile_filter_sql
turnstile_filter_sql=turnstile_filter_sql,
fligel_filter_sql=fligel_filter_sql
)
connection = None
+21 -1
View File
@@ -109,4 +109,24 @@ def cleanup_old_intermediate_snapshots(days_to_keep_all: int = 2) -> int:
if deleted_rows_count > 0:
logger.info(f"Ротация завершена: удалено {deleted_rows_count} строк промежуточных срезов.")
return deleted_rows_count
return deleted_rows_count
def purge_day_intermediate_snapshots(date_str: str) -> int:
"""
Удаляет все промежуточные дневные срезы за указанную дату,
оставляя нетронутым итоговый вечерний срез Y..._FINAL.
"""
clean_date = date_str.replace('_', '.')
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM scud_logs
WHERE log_date = ?
AND snapshot_id NOT LIKE 'Y%'
AND snapshot_id NOT LIKE '%_FINAL%'
""", (clean_date,))
deleted = cursor.rowcount
conn.commit()
if deleted > 0:
logger.info(f"[Retention] Удалено {deleted} строк промежуточных срезов за {clean_date}. Сохранен только финишный Y.")
return deleted
+5333 -2989
View File
File diff suppressed because it is too large Load Diff