Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565cf5af37 | ||
|
|
86f223a21e | ||
|
|
b1796de852 | ||
|
|
aa1141fb88 | ||
|
|
66a17bde45 | ||
|
|
baa5073b88 |
@@ -5,6 +5,10 @@ ROLE: Фасад ядра базы данных с полной обратной
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
from config import OUTPUT_DIR
|
||||
|
||||
from core.connection import get_connection, DB_PATH
|
||||
from core.schema import init_all_tables
|
||||
|
||||
@@ -33,3 +37,21 @@ from core.repositories.zup_repo import (
|
||||
)
|
||||
|
||||
init_db = init_all_tables
|
||||
|
||||
def dump_database_to_excel(out_filename: str = "db_dump_full.xlsx") -> str:
|
||||
"""Создает полный дамп всех ключевых таблиц SQLite в многостраничный Excel."""
|
||||
out_path = os.path.join(OUTPUT_DIR, out_filename)
|
||||
tables = [
|
||||
'scud_logs', 'scud_events_raw', 'zup_staff', 'zup_absences',
|
||||
'anomalies_history', 'ai_knowledge_base', 'chat_messages',
|
||||
'session_states', 'system_prompt_nodes', 'tasks',
|
||||
'exceptions_registry', 'manual_absences', 'person_identity_mapping'
|
||||
]
|
||||
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
|
||||
for table in tables:
|
||||
try:
|
||||
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
|
||||
df.to_excel(writer, sheet_name=table[:31], index=False)
|
||||
except Exception:
|
||||
pass
|
||||
return out_path
|
||||
@@ -1,178 +0,0 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/ai_engine/agent.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: modules / ai_engine
|
||||
ROLE: Лаконичный нативный оркестратор Function Calling, диспетчер handlers
|
||||
и менеджер свободных диалогов (Topic Drift).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[AGENT_PIPELINE_ENTRY]: Главная точка входа process_chat_message.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
|
||||
from modules.web_api.llm.schemas import TOOLS_SCHEMA
|
||||
from modules.web_api.llm.core.ollama_client import call_ollama_chat
|
||||
from modules.web_api.llm.core.fast_path import handle_fast_path_intercept
|
||||
from modules.web_api.llm.core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
|
||||
from modules.web_api.llm.core.context_manager import mark_last_user_message_ephemeral, close_tool_session_and_cleanup
|
||||
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
|
||||
from modules.web_api.llm.db.db_prompts import (
|
||||
db_get_session_state, db_clear_session_state, db_set_session_state,
|
||||
db_get_stats, db_get_anomalies, db_get_reference
|
||||
)
|
||||
from services.knowledge.service import get_rules
|
||||
|
||||
from .context_builder import build_agent_system_context
|
||||
from .handlers.task_handler import handle_tasks_call
|
||||
from .handlers.prompt_handler import handle_prompt_call
|
||||
from .handlers.snapshot_handler import handle_snapshots_call
|
||||
|
||||
logger = logging.getLogger("AI_AGENT")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
# ANCHOR[AGENT_PIPELINE_ENTRY]
|
||||
def process_chat_message(
|
||||
user_id: int,
|
||||
user_message: str,
|
||||
file_context: str = "",
|
||||
image_b64: Optional[str] = None,
|
||||
chat_history: List[Dict[str, Any]] = None,
|
||||
session_id: str = "web_session_main"
|
||||
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""Главный конвейер обработки сообщений чата."""
|
||||
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
||||
|
||||
session_state = db_get_session_state(session_id)
|
||||
if not session_state:
|
||||
db_purge_ephemeral_messages(session_id)
|
||||
|
||||
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
|
||||
|
||||
# 1. Быстрый Fast-Path перехват кнопок подтверждения
|
||||
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
|
||||
if fast_path_res:
|
||||
return fast_path_res
|
||||
|
||||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||||
db_history = db_get_chat_history(session_id, limit=20)
|
||||
system_prompt = build_agent_system_context(user_id, session_state)
|
||||
|
||||
user_msg_obj = {"role": "user", "content": full_user_content}
|
||||
|
||||
try:
|
||||
if image_b64:
|
||||
user_msg_obj["images"] = [image_b64]
|
||||
messages = [{"role": "system", "content": "Строгий модуль OCR. Перепиши весь текст буква в букву."}, user_msg_obj]
|
||||
msg = call_ollama_chat(messages, is_vision=True)
|
||||
else:
|
||||
clean_history = [dict(m) for m in db_history]
|
||||
for m in clean_history: m.pop("images", None)
|
||||
messages = [{"role": "system", "content": system_prompt}] + clean_history + [user_msg_obj]
|
||||
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
|
||||
|
||||
raw_reply = msg.get("content", "")
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
|
||||
# 2. Гибридный семантический классификатор намерений (Fallback Safety Net)
|
||||
tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls)
|
||||
|
||||
# 3. Исполнение инструментов через изолированные handlers
|
||||
if tool_calls:
|
||||
tool = tool_calls[0]
|
||||
fn_name = tool["function"]["name"]
|
||||
fn_args = tool["function"].get("arguments", {})
|
||||
if isinstance(fn_args, str):
|
||||
try: fn_args = json.loads(fn_args)
|
||||
except Exception: fn_args = {}
|
||||
|
||||
logger.info(f"Вызов инструмента: {fn_name} с аргументами: {fn_args}")
|
||||
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
|
||||
mark_last_user_message_ephemeral(session_id)
|
||||
|
||||
state_data = session_state.get("data_json") or {} if session_state else {}
|
||||
|
||||
if fn_name in ["db_get_tasks", "db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
|
||||
return handle_tasks_call(fn_name, fn_args, user_id, session_id)
|
||||
|
||||
elif fn_name in ["db_get_system_prompt", "db_prompt_node_edit"]:
|
||||
return handle_prompt_call(fn_name, fn_args, session_id)
|
||||
|
||||
elif fn_name in ["db_get_snapshots", "db_delete_snapshots"]:
|
||||
return handle_snapshots_call(fn_name, fn_args, session_id, user_message, state_data)
|
||||
|
||||
elif fn_name == "db_get_rules":
|
||||
res_str = json.dumps(get_rules(), ensure_ascii=False)
|
||||
elif fn_name == "db_get_stats":
|
||||
res_str = json.dumps(db_get_stats(), ensure_ascii=False)
|
||||
elif fn_name == "db_get_anomalies":
|
||||
res_str = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
|
||||
elif fn_name == "db_get_reference":
|
||||
res_str = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
|
||||
else:
|
||||
res_str = "{}"
|
||||
|
||||
messages.append(msg)
|
||||
messages.append({"role": "tool", "content": res_str})
|
||||
sec_msg = call_ollama_chat(messages, is_vision=False)
|
||||
final_content = clean_raw_tool_tags(clean_output(sec_msg.get("content", ""))) or "Запрос выполнен."
|
||||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
|
||||
return final_content, db_get_chat_history(session_id), None
|
||||
|
||||
# 4. Обычный содержательный диалог и управление Topic Drift
|
||||
final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан."
|
||||
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто,", "почемучто", "почему-то"]:
|
||||
if final_reply.lower().startswith(artifact):
|
||||
final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
|
||||
|
||||
action_payload = None
|
||||
|
||||
# Обработка ответа "нет / спасибо" в режиме открытого инструмента
|
||||
if session_state and any(kw in user_message.lower() for kw in ["нет", "спасибо", "не надо", "готово", "хватит"]):
|
||||
close_tool_session_and_cleanup(session_id, close_reason="USER_DISMISSED_TOOL")
|
||||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
||||
return final_reply, db_get_chat_history(session_id), None
|
||||
|
||||
# Инкремент счётчика шагов в сторону от инструмента (Topic Drift)
|
||||
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW", "SNAPSHOTS_VIEW"]:
|
||||
state_type = session_state.get("state_type")
|
||||
state_data = session_state.get("data_json") or {}
|
||||
if not isinstance(state_data, dict):
|
||||
state_data = {}
|
||||
|
||||
idle_turns = state_data.get("idle_turns", 0) + 1
|
||||
state_data["idle_turns"] = idle_turns
|
||||
|
||||
if idle_turns >= 4:
|
||||
# 4-й шаг не по теме: бесшумно закрываем сессию и вычищаем эфемерные карточки
|
||||
close_tool_session_and_cleanup(session_id, close_reason="TOPIC_DRIFT_TIMEOUT")
|
||||
elif idle_turns == 3:
|
||||
# 3-й шаг: выводим вежливое напоминание с кнопками
|
||||
tool_label = "системным промптом" if "PROMPT" in state_type else "снапшотами СКУД"
|
||||
guard_question = f"Желаете продолжить работу с {tool_label}?"
|
||||
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
|
||||
action_payload = {
|
||||
"type": "FOLLOW_UP_ACTION",
|
||||
"buttons": [
|
||||
{"label": "Показать снова", "value": "покажи системный промпт" if "PROMPT" in state_type else "покажи снапшоты", "style": "primary"},
|
||||
{"label": "Завершить", "value": "нет, спасибо", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
db_set_session_state(session_id, state_type, state_data)
|
||||
else:
|
||||
# 1-й и 2-й шаг: фиксируем обновленный счётчик
|
||||
db_set_session_state(session_id, state_type, state_data)
|
||||
|
||||
is_ephem_reply = 1 if "актуальный системный промпт:" in final_reply.lower() else 0
|
||||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=is_ephem_reply)
|
||||
return final_reply, db_get_chat_history(session_id), action_payload
|
||||
|
||||
except Exception as ex:
|
||||
logger.exception(f"Ошибка в агенте: {ex}")
|
||||
return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/ai_engine/context_builder.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: modules / ai_engine
|
||||
ROLE: Динамическая сборка системного промпта из БД, календаря и контекста сессий.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[CONTEXT_BUILDER_MAIN]: Формирование полного системного контекста для LLM.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from services.prompts.service import get_active_system_prompt
|
||||
from modules.web_api.llm.core.calendar_utils import get_dynamic_calendar_context
|
||||
|
||||
|
||||
# ANCHOR[CONTEXT_BUILDER_MAIN]
|
||||
def build_agent_system_context(
|
||||
user_id: int,
|
||||
session_state: Optional[Dict[str, Any]]
|
||||
) -> str:
|
||||
"""Формирует полный динамический системный контекст агента с правилами из БД."""
|
||||
|
||||
# 1. Загружаем актуальный базовый системный промпт из SQLite БД
|
||||
base_system_prompt = get_active_system_prompt()
|
||||
|
||||
# 2. Серверный календарь и контекст дат
|
||||
calendar_context = get_dynamic_calendar_context()
|
||||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||||
|
||||
# 3. Контекст текущего активного стейта сессии
|
||||
current_state_type = session_state.get("state_type") if session_state else None
|
||||
state_data = session_state.get("data_json") or {} if session_state else {}
|
||||
if not isinstance(state_data, dict):
|
||||
state_data = {}
|
||||
|
||||
active_state_context = ""
|
||||
if current_state_type == "PROMPT_PREVIEW":
|
||||
active_state_context = (
|
||||
"\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n"
|
||||
"- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n"
|
||||
)
|
||||
elif current_state_type == "PROMPT_FOLLOWUP":
|
||||
active_state_context = (
|
||||
"\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
|
||||
"- Оператор просматривает или редактирует системный промпт.\n"
|
||||
"- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
|
||||
"- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n"
|
||||
"- Запрещено путать ADD и DELETE.\n"
|
||||
)
|
||||
elif current_state_type == "SNAPSHOTS_VIEW":
|
||||
active_date = state_data.get("query_date", "выбранную дату")
|
||||
active_state_context = (
|
||||
f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n"
|
||||
f"- Сейчас на экране отображаются снапшоты за {active_date}.\n"
|
||||
f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n"
|
||||
f"- Если оператор запрашивает ДРУГУЮ дату или день недели (например: 'за вчера', 'а за 13.08') — "
|
||||
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n"
|
||||
f"- Запрещено генерировать текст за другую дату по памяти.\n"
|
||||
)
|
||||
elif current_state_type == "SNAPSHOT_DELETE_CONFIRM":
|
||||
active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n"
|
||||
elif current_state_type == "TASK_DELETE_CONFIRM":
|
||||
active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n"
|
||||
|
||||
# 4. Сборка полного системного сообщения
|
||||
return (
|
||||
f"{base_system_prompt}\n\n"
|
||||
f"СТРОГИЕ ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ:\n"
|
||||
f"1. Для системного промпта:\n"
|
||||
f" - 'добавь X.Y Текст' -> СРАЗУ вызывай db_prompt_node_edit(action='ADD', section_id=X, item_id=Y, content='Текст').\n"
|
||||
f" - 'удали X.Y' -> СРАЗУ вызывай db_prompt_node_edit(action='DELETE', section_id=X, item_id=Y, content='').\n"
|
||||
f" - 'покажи системный промпт' -> СРАЗУ вызывай db_get_system_prompt().\n"
|
||||
f"2. Для задач:\n"
|
||||
f" - 'удали задачу N' -> СРАЗУ вызывай db_tasks_edit(action='DELETE', task_id='N').\n"
|
||||
f" - 'возьми в работу N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='IN_PROGRESS').\n"
|
||||
f" - 'заверши N' / 'готово N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='COMPLETED').\n"
|
||||
f"3. Запрещено задавать вопросы и писать подтверждения текстом — СРАЗУ вызывай соответствующий инструмент!\n\n"
|
||||
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
|
||||
f"- Пользователь: {user_info}\n"
|
||||
f"- {calendar_context}\n"
|
||||
f"{active_state_context}"
|
||||
)
|
||||
@@ -1,98 +0,0 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/ai_engine/handlers/prompt_handler.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: modules / ai_engine / handlers
|
||||
ROLE: Изолированная обработка команд управления системным промптом.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Tuple, Optional
|
||||
from services.prompts.service import get_active_system_prompt, create_prompt_preview
|
||||
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
|
||||
from modules.web_api.llm.db.db_prompts import db_set_session_state
|
||||
|
||||
|
||||
# ANCHOR[PROMPT_HANDLER_DISPATCH]
|
||||
def handle_prompt_call(
|
||||
fn_name: str,
|
||||
fn_args: Dict[str, Any],
|
||||
session_id: str
|
||||
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
|
||||
"""Обрабатывает вызовы просмотра и изменения системного промпта."""
|
||||
|
||||
# 1. Просмотр промпта с кнопкой быстрого перехода в редактор
|
||||
if fn_name == "db_get_system_prompt":
|
||||
active_prompt = get_active_system_prompt()
|
||||
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
|
||||
|
||||
# Сохраняем состояние сессии для возможности мгновенного редактирования и подтверждения
|
||||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||||
"draft_text": active_prompt,
|
||||
"action": "MANUAL_EDIT",
|
||||
"idle_turns": 0
|
||||
})
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "PROMPT_PREVIEW",
|
||||
"raw_draft": active_prompt,
|
||||
"baseline_prompt": active_prompt,
|
||||
"buttons": [
|
||||
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"},
|
||||
{"label": "Готово", "value": "нет, спасибо", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
|
||||
# 2. Предпросмотр точечных или пакетных изменений (ADD / EDIT / DELETE / BATCH_DELETE)
|
||||
action = str(fn_args.get("action", "ADD")).upper()
|
||||
nodes_list = fn_args.get("nodes_list", [])
|
||||
delete_nodes_tuples = []
|
||||
|
||||
if nodes_list:
|
||||
for n_str in nodes_list:
|
||||
parts = str(n_str).strip().split(".")
|
||||
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||
delete_nodes_tuples.append((int(parts[0]), int(parts[1])))
|
||||
|
||||
sec_id = None
|
||||
itm_id = None
|
||||
try:
|
||||
if fn_args.get("section_id") is not None:
|
||||
sec_id = int(str(fn_args.get("section_id")).strip())
|
||||
if fn_args.get("item_id") is not None:
|
||||
itm_id = int(str(fn_args.get("item_id")).strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
content = str(fn_args.get("content", "")).strip()
|
||||
|
||||
merged_prompt, diff_html, baseline_prompt = create_prompt_preview(
|
||||
action=action,
|
||||
section_id=sec_id,
|
||||
item_id=itm_id,
|
||||
content=content,
|
||||
delete_nodes=delete_nodes_tuples if delete_nodes_tuples else None
|
||||
)
|
||||
|
||||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||||
"draft_text": merged_prompt,
|
||||
"action": "MANUAL_EDIT",
|
||||
"section_id": sec_id,
|
||||
"item_id": itm_id,
|
||||
"content": content,
|
||||
"idle_turns": 0
|
||||
})
|
||||
|
||||
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
|
||||
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
||||
return preview_reply, db_get_chat_history(session_id), {
|
||||
"type": "PROMPT_PREVIEW",
|
||||
"raw_draft": merged_prompt,
|
||||
"baseline_prompt": baseline_prompt,
|
||||
"buttons": [
|
||||
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
||||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||||
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/ai_engine/handlers/snapshot_handler.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: modules / ai_engine / handlers
|
||||
ROLE: Изолированная обработка запросов просмотра и удаления срезов СКУД.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[SNAPSHOT_HANDLER_DISPATCH]: Обработка db_get_snapshots и db_delete_snapshots.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Tuple, Optional, List
|
||||
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
|
||||
from modules.web_api.llm.core.calendar_utils import parse_relative_date_ru
|
||||
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
|
||||
from modules.web_api.llm.db.db_prompts import db_set_session_state
|
||||
|
||||
|
||||
# ANCHOR[SNAPSHOT_HANDLER_DISPATCH]
|
||||
def handle_snapshots_call(
|
||||
fn_name: str,
|
||||
fn_args: Dict[str, Any],
|
||||
session_id: str,
|
||||
user_message: str,
|
||||
state_data: Dict[str, Any]
|
||||
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
|
||||
"""Обрабатывает запросы реестра и безопасного удаления срезов."""
|
||||
|
||||
# 1. Получение срезов
|
||||
if fn_name == "db_get_snapshots":
|
||||
date_param = fn_args.get("date_str")
|
||||
if not date_param and user_message:
|
||||
date_param = parse_relative_date_ru(user_message)
|
||||
|
||||
snapshots_res = get_snapshots_registry(date_str=date_param)
|
||||
query_date = snapshots_res.get("query_date", "выбранную дату")
|
||||
|
||||
db_set_session_state(session_id, "SNAPSHOTS_VIEW", snapshots_res)
|
||||
reply_text = f"Реестр срезов СКУД за {query_date}:"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "SNAPSHOTS_CARD",
|
||||
"data": snapshots_res
|
||||
}
|
||||
|
||||
# 2. Удаление срезов (двухфазное подтверждение)
|
||||
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
|
||||
raw_ids = fn_args.get("snapshot_ids") or []
|
||||
is_confirmed = fn_args.get("confirmed", False)
|
||||
|
||||
if raw_id and not raw_ids:
|
||||
if isinstance(raw_id, str) and "," in raw_id:
|
||||
raw_ids = [s.strip() for s in raw_id.split(",")]
|
||||
else:
|
||||
raw_ids = [raw_id]
|
||||
|
||||
safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
|
||||
if not safe_ids:
|
||||
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), None
|
||||
|
||||
if not is_confirmed:
|
||||
query_date = state_data.get("query_date", "")
|
||||
db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
|
||||
"snapshot_ids": safe_ids,
|
||||
"query_date": query_date,
|
||||
"idle_turns": 0
|
||||
})
|
||||
ids_str = ", ".join(safe_ids)
|
||||
reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "SNAPSHOT_DELETE_CONFIRM",
|
||||
"buttons": [
|
||||
{"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
|
||||
{"label": "Отмена", "value": "отмена", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
else:
|
||||
delete_snapshots_safely(snapshot_ids=safe_ids)
|
||||
query_date = state_data.get("query_date", "")
|
||||
updated_data = get_snapshots_registry(date_str=query_date)
|
||||
|
||||
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "SNAPSHOTS_CARD",
|
||||
"data": updated_data
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/ai_engine/handlers/task_handler.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: modules / ai_engine / handlers
|
||||
ROLE: Изолированная обработка вызовов инструментов задач (get, edit, delete, export).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[TASK_HANDLER_DISPATCH]: Обработка вызовов db_get_tasks и db_tasks_edit.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Tuple, Optional
|
||||
from services.tasks.service import get_tasks, execute_task_action
|
||||
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
|
||||
from modules.web_api.llm.db.db_prompts import db_set_session_state
|
||||
|
||||
|
||||
# ANCHOR[TASK_HANDLER_DISPATCH]
|
||||
def handle_tasks_call(
|
||||
fn_name: str,
|
||||
fn_args: Dict[str, Any],
|
||||
user_id: int,
|
||||
session_id: str
|
||||
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
|
||||
"""Обрабатывает нативные tool-вызовы по задачам."""
|
||||
|
||||
# 1. Просмотр задач
|
||||
if fn_name == "db_get_tasks":
|
||||
raw_tasks = get_tasks(user_id, status=fn_args.get("status"))
|
||||
reply_text = "Вот интерактивный список ваших текущих задач:"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "TASK_INTERACTIVE_CARD",
|
||||
"tasks": raw_tasks
|
||||
}
|
||||
|
||||
# 2. Модификация / Удаление / Экспорт
|
||||
action = (fn_args.get("action") or "UPDATE").upper()
|
||||
if fn_name == "db_add_task": action = "ADD"
|
||||
elif fn_name == "db_delete_task": action = "DELETE"
|
||||
elif fn_name == "db_update_task_status": action = "UPDATE"
|
||||
|
||||
# Двухфазное подтверждение удаления
|
||||
if action == "DELETE":
|
||||
task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
|
||||
db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
|
||||
reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "TASK_DELETE_CONFIRM",
|
||||
"buttons": [
|
||||
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
|
||||
{"label": "Отмена", "value": "отмена", "style": "secondary"}
|
||||
]
|
||||
}
|
||||
|
||||
# Экспорт в Markdown
|
||||
elif action == "EXPORT":
|
||||
export_res = execute_task_action(
|
||||
user_id=user_id,
|
||||
action="EXPORT",
|
||||
filename=fn_args.get("filename"),
|
||||
status=fn_args.get("status")
|
||||
)
|
||||
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
||||
|
||||
action_payload = None
|
||||
if export_res.get("status") == "success":
|
||||
action_payload = {
|
||||
"type": "FILE_DOWNLOAD_CARD",
|
||||
"filename": export_res.get("filename"),
|
||||
"download_url": export_res.get("download_url"),
|
||||
"tasks_count": export_res.get("tasks_count")
|
||||
}
|
||||
return reply_text, db_get_chat_history(session_id), action_payload
|
||||
|
||||
# Создание и обновление
|
||||
else:
|
||||
res = execute_task_action(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
task_id=fn_args.get("task_id"),
|
||||
title=fn_args.get("title"),
|
||||
priority=fn_args.get("priority", "MEDIUM"),
|
||||
status=fn_args.get("status"),
|
||||
module=fn_args.get("module", "general"),
|
||||
due_date=fn_args.get("due_date")
|
||||
)
|
||||
raw_tasks = get_tasks(user_id)
|
||||
reply_text = res.get("message", "Операция над задачами выполнена.")
|
||||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||||
return reply_text, db_get_chat_history(session_id), {
|
||||
"type": "TASK_INTERACTIVE_CARD",
|
||||
"tasks": raw_tasks
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/llm/db/connection.py
|
||||
ROLE: Реэкспорт единого подключения к БД из core.connection.
|
||||
===============================================================================
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
# Динамический путь к общей БД в корне проекта
|
||||
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../"))
|
||||
DB_PATH = os.path.join(BASE_ROOT, "data", "scud_orion_ai.db")
|
||||
|
||||
def get_db_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON;")
|
||||
conn.execute("PRAGMA journal_mode = WAL;")
|
||||
conn.execute("PRAGMA synchronous = NORMAL;")
|
||||
return conn
|
||||
from core.connection import get_connection as get_db_connection, DB_PATH
|
||||
@@ -7,7 +7,7 @@ from .connection import get_db_connection
|
||||
|
||||
def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral: int = 0) -> None:
|
||||
"""Сохраняет сообщение в БД (is_ephemeral=1 для временных служебных шагов, 0 для постоянных)."""
|
||||
conn = get_db_connection()
|
||||
conn = get_db_connection(row_factory=True)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO chat_messages (session_id, role, content, is_ephemeral)
|
||||
@@ -17,20 +17,19 @@ def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Получает последние сообщения истории диалога в хронологическом порядке."""
|
||||
conn = get_db_connection()
|
||||
def db_get_chat_history(session_id: str, limit: int = 50) -> list:
|
||||
"""Возвращает историю сообщений диалога для сессии."""
|
||||
with get_db_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT role, content
|
||||
SELECT role, content, is_ephemeral, created_at
|
||||
FROM chat_messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""", (session_id, limit))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
|
||||
return [{"role": r["role"], "content": r["content"], "is_ephemeral": r["is_ephemeral"]} for r in reversed(rows)]
|
||||
|
||||
|
||||
def db_purge_ephemeral_messages(session_id: str) -> int:
|
||||
@@ -38,7 +37,7 @@ def db_purge_ephemeral_messages(session_id: str) -> int:
|
||||
Физически удаляет все временные служебные сообщения выбранной сессии
|
||||
после завершения сценария работы с инструментом.
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
conn = get_db_connection(row_factory=True)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1", (session_id,))
|
||||
deleted = cursor.rowcount
|
||||
@@ -49,8 +48,40 @@ def db_purge_ephemeral_messages(session_id: str) -> int:
|
||||
|
||||
def db_clear_chat_history(session_id: str) -> None:
|
||||
"""Полная очистка всех сообщений сессии."""
|
||||
conn = get_db_connection()
|
||||
conn = get_db_connection(row_factory=True)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def db_clear_all_chat_context(session_id: str = None, purge_all: bool = False) -> int:
|
||||
"""Удаляет сообщения чата и стейты сессий."""
|
||||
with get_db_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
if purge_all:
|
||||
if session_id:
|
||||
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
|
||||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||||
else:
|
||||
cursor.execute("DELETE FROM chat_messages")
|
||||
cursor.execute("DELETE FROM session_states")
|
||||
cnt = cursor.rowcount
|
||||
conn.commit()
|
||||
return cnt
|
||||
|
||||
query = """
|
||||
DELETE FROM chat_messages
|
||||
WHERE is_ephemeral = 1
|
||||
OR content LIKE '%Предпросмотр изменений%'
|
||||
OR content LIKE '%Удален пункт:%'
|
||||
OR content LIKE '%добавлен пункт:%'
|
||||
"""
|
||||
if session_id:
|
||||
cursor.execute(query + " AND session_id = ?", (session_id,))
|
||||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||||
else:
|
||||
cursor.execute(query)
|
||||
cursor.execute("DELETE FROM session_states")
|
||||
cnt = cursor.rowcount
|
||||
conn.commit()
|
||||
return cnt
|
||||
@@ -17,12 +17,12 @@
|
||||
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,17 +33,16 @@
|
||||
#chat-messages-container code {
|
||||
font-size: 13.5px !important;
|
||||
}
|
||||
/* Смещение для точной прокрутки под фиксированный заголовок */
|
||||
.user-chat-bubble {
|
||||
scroll-margin-top: 24px !important;
|
||||
}
|
||||
</style>
|
||||
</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">
|
||||
|
||||
<!-- ЛЕВАЯ КОЛОНКА (ДИНАМИЧЕСКИЙ САЙДБАР 5-ХАБОВ) -->
|
||||
<!-- ЛЕВАЯ КОЛОНКА (ДИНАМИЧЕСКИЙ САЙДБАР) -->
|
||||
<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">
|
||||
|
||||
<!-- ДИНАМИЧЕСКИЙ ТАБ-БАР ХАБОВ И ПОДВКЛАДОК -->
|
||||
@@ -129,20 +128,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контейнер строки ввода сообщения -->
|
||||
<!-- СТРОКА ВВОДА -->
|
||||
<div class="px-4 py-2 bg-white border-t border-slate-200">
|
||||
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
|
||||
<!-- Скрепка файлов -->
|
||||
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0">
|
||||
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0" title="Прикрепить файл">
|
||||
<i class="fa-solid fa-paperclip text-xs"></i>
|
||||
</button>
|
||||
<input type="file" id="file-upload-input" class="hidden" />
|
||||
|
||||
<!-- Поле ввода -->
|
||||
<textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..."
|
||||
class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>
|
||||
|
||||
<!-- Кнопка отправки -->
|
||||
<button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
|
||||
<i class="fa-solid fa-paper-plane text-[10px]"></i>
|
||||
</button>
|
||||
@@ -151,551 +147,10 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО: УДАЛЕННЫЙ СОТРУДНИК -->
|
||||
<div id="remote-worker-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 max-w-md w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="remote-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<i class="fa-solid fa-house-laptop text-emerald-600"></i>
|
||||
<span>Параметры удаленной работы</span>
|
||||
</h3>
|
||||
<button type="button" onclick="closeRemoteWorkerModal()" class="text-slate-400 hover:text-slate-600">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
|
||||
<div id="modals-container"></div>
|
||||
|
||||
<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="Например: Иванов Иван Иванович"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Подразделение:</label>
|
||||
<input type="text" id="rw-dept" 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>
|
||||
|
||||
<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="rw-date-from"
|
||||
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 text-slate-700" />
|
||||
<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="rw-date-to"
|
||||
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 text-slate-700" />
|
||||
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = бессрочно</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rw-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||||
<button type="button" onclick="closeRemoteWorkerModal()"
|
||||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" id="rw-submit-btn"
|
||||
class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО: МЕСТНАЯ КОМАНДИРОВКА И ИНОЕ С АВТОКОМПЛИТОМ -->
|
||||
<div id="manual-absence-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 max-w-md w-full p-6 flex flex-col gap-4">
|
||||
<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>
|
||||
</h3>
|
||||
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Поле ФИО с автодополнением по 1С -->
|
||||
<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" />
|
||||
<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>
|
||||
</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"
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
|
||||
<input type="date" id="manual-absence-end-date"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО: ДОБАВЛЕНИЕ В РЕЕСТРЫ ИСКЛЮЧЕНИЙ И ТУРНИКЕТОВ -->
|
||||
<div id="exception-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 max-w-md w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="exception-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<i class="fa-solid fa-user-shield text-indigo-600"></i>
|
||||
<span id="exception-modal-header-text">Добавление в реестр</span>
|
||||
</h3>
|
||||
<button type="button" onclick="closeExceptionModal()" class="text-slate-400 hover:text-slate-600">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="exception-modal-form" onsubmit="submitExceptionModalForm(event)" class="flex flex-col gap-3">
|
||||
<input type="hidden" id="exception-category-input" value="" />
|
||||
|
||||
<!-- Поле ввода значения с автокомплитом -->
|
||||
<div class="relative">
|
||||
<label id="exception-value-label" class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
|
||||
<input type="text" id="exception-value-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" />
|
||||
<!-- Выпадающие подсказки из 1С -->
|
||||
<div id="exception-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>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Примечание / основание (опционально):</label>
|
||||
<input type="text" id="exception-comment-input" 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" />
|
||||
</div>
|
||||
|
||||
<div id="exception-error-msg" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||||
<button type="button" onclick="closeExceptionModal()"
|
||||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" id="exception-submit-btn"
|
||||
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-check text-xs"></i>
|
||||
<span>Добавить</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО АВТОРИЗАЦИИ -->
|
||||
<div id="auth-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 max-w-sm w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center font-bold text-lg shadow">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-sm font-bold text-slate-800">Авторизация в системе</h2>
|
||||
<p class="text-[11px] text-slate-400">SCUD Orion AI Security Access</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="auth-form" onsubmit="handleLoginSubmit(event)" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Имя пользователя:</label>
|
||||
<input type="text" id="auth-username" 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" placeholder="Логин" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Пароль:</label>
|
||||
<input type="password" id="auth-password" 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" placeholder="••••••••" />
|
||||
</div>
|
||||
<div id="auth-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
<button type="submit" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg text-xs shadow transition mt-1">
|
||||
Войти в систему
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО СМЕНЫ ПАРОЛЯ -->
|
||||
<div id="profile-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 max-w-sm w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
|
||||
</h3>
|
||||
<button onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
<form onsubmit="handleChangePassword(event)" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Текущий пароль:</label>
|
||||
<input type="password" id="old-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Новый пароль (мин. 4 симв.):</label>
|
||||
<input type="password" id="new-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div id="pass-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
<div class="flex items-center justify-end gap-2 mt-2">
|
||||
<button type="button" onclick="closeProfileModal()" class="px-3 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium">Отмена</button>
|
||||
<button type="submit" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО ПАНЕЛИ АДМИНИСТРАТОРА -->
|
||||
<div id="admin-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 max-w-lg w-full p-6 flex flex-col gap-4 max-h-[85vh]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление учетными записями
|
||||
</h3>
|
||||
<button onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
|
||||
<form onsubmit="handleCreateUser(event)" class="p-3 bg-slate-50 border border-slate-200 rounded-xl flex flex-col gap-2">
|
||||
<span class="text-xs font-bold text-slate-700">Создать нового пользователя:</span>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input type="text" id="new-user-username" placeholder="Логин" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||||
<input type="password" id="new-user-password" placeholder="Пароль" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||||
</div>
|
||||
<input type="text" id="new-user-fullname" placeholder="ФИО" class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer">
|
||||
<input type="checkbox" id="new-user-admin" class="rounded border-slate-300 text-indigo-600" />
|
||||
<span>Права администратора</span>
|
||||
</label>
|
||||
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex-1 overflow-y-auto flex flex-col gap-1.5" id="admin-users-list">
|
||||
<div class="text-center py-4 text-xs text-slate-400">Загрузка пользователей...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ -->
|
||||
<script src="/static/js/auth.js?v=2.5.7"></script>
|
||||
<script src="/static/js/tasks.js?v=2.5.7"></script>
|
||||
<script src="/static/js/manual_absences.js?v=2.5.7"></script>
|
||||
<script src="/static/js/sidebar.js?v=2.5.7"></script>
|
||||
<script src="/static/js/chat/task_widget.js?v=2.5.7"></script>
|
||||
<script src="/static/js/chat/core.js?v=2.5.7"></script>
|
||||
<script src="/static/js/app.js?v=2.5.7"></script>
|
||||
|
||||
<script>
|
||||
function showAuthModal() {
|
||||
const modal = document.getElementById("auth-modal");
|
||||
if (modal) modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideAuthModal() {
|
||||
const modal = document.getElementById("auth-modal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
}
|
||||
|
||||
function updateUIState() {
|
||||
const nameEl = document.getElementById("user-display-name");
|
||||
const roleEl = document.getElementById("user-display-role");
|
||||
const adminBtn = document.getElementById("admin-panel-btn");
|
||||
|
||||
if (nameEl) nameEl.innerText = AuthManager.getFullName();
|
||||
if (roleEl) roleEl.innerText = AuthManager.isAdmin() ? "Администратор" : "Оператор";
|
||||
|
||||
if (adminBtn) {
|
||||
if (AuthManager.isAdmin()) {
|
||||
adminBtn.classList.remove("hidden");
|
||||
} else {
|
||||
adminBtn.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginSubmit(e) {
|
||||
e.preventDefault();
|
||||
const uInput = document.getElementById("auth-username");
|
||||
const pInput = document.getElementById("auth-password");
|
||||
const errEl = document.getElementById("auth-error");
|
||||
errEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: uInput.value.trim(),
|
||||
password: pInput.value
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
if (window.SidebarManager) SidebarManager.setHub('TASKS');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
errEl.innerText = err.detail || "Неверный логин или пароль";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.innerText = "Ошибка соединения с сервером";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function openProfileModal() {
|
||||
document.getElementById("profile-modal").classList.remove("hidden");
|
||||
}
|
||||
function closeProfileModal() {
|
||||
document.getElementById("profile-modal").classList.add("hidden");
|
||||
}
|
||||
async function handleChangePassword(e) {
|
||||
e.preventDefault();
|
||||
const oldP = document.getElementById("old-pass").value;
|
||||
const newP = document.getElementById("new-pass").value;
|
||||
const errEl = document.getElementById("pass-error");
|
||||
errEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: AuthManager.getAuthHeaders(),
|
||||
body: JSON.stringify({ old_password: oldP, new_password: newP })
|
||||
});
|
||||
if (res.ok) {
|
||||
alert("Пароль успешно изменен");
|
||||
closeProfileModal();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
errEl.innerText = err.detail || "Ошибка изменения пароля";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.innerText = "Ошибка сети";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function openAdminModal() {
|
||||
document.getElementById("admin-modal").classList.remove("hidden");
|
||||
loadAdminUsers();
|
||||
}
|
||||
function closeAdminModal() {
|
||||
document.getElementById("admin-modal").classList.add("hidden");
|
||||
}
|
||||
async function loadAdminUsers() {
|
||||
const listEl = document.getElementById("admin-users-list");
|
||||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>`;
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/users", { headers: AuthManager.getAuthHeaders() });
|
||||
if (res.ok) {
|
||||
const users = await res.json();
|
||||
listEl.innerHTML = users.map(u => `
|
||||
<div class="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs">
|
||||
<div>
|
||||
<div class="font-bold text-slate-800">${escapeHtml(u.full_name || u.username)} <span class="text-slate-400 font-mono text-[10px]">(${escapeHtml(u.username)})</span></div>
|
||||
<div class="text-[10px] ${u.is_admin ? 'text-indigo-600 font-bold' : 'text-slate-400'}">${u.is_admin ? 'Администратор' : 'Оператор'}</div>
|
||||
</div>
|
||||
<button onclick="deleteAdminUser(${u.id}, '${escapeHtml(u.username)}')" class="text-slate-400 hover:text-rose-600 p-1.5" title="Удалить">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка загрузки пользователей</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка сети</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser(e) {
|
||||
e.preventDefault();
|
||||
const u = document.getElementById("new-user-username").value;
|
||||
const p = document.getElementById("new-user-password").value;
|
||||
const f = document.getElementById("new-user-fullname").value;
|
||||
const a = document.getElementById("new-user-admin").checked;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/users", {
|
||||
method: "POST",
|
||||
headers: AuthManager.getAuthHeaders(),
|
||||
body: JSON.stringify({ username: u, password: p, full_name: f, is_admin: a })
|
||||
});
|
||||
if (res.ok) {
|
||||
document.getElementById("new-user-username").value = "";
|
||||
document.getElementById("new-user-password").value = "";
|
||||
document.getElementById("new-user-fullname").value = "";
|
||||
document.getElementById("new-user-admin").checked = false;
|
||||
loadAdminUsers();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.detail || "Ошибка создания пользователя");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Ошибка сети");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAdminUser(id, username) {
|
||||
if (!confirm(`Удалить пользователя ${username}?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: AuthManager.getAuthHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
loadAdminUsers();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.detail || "Ошибка удаления");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Ошибка сети");
|
||||
}
|
||||
}
|
||||
|
||||
function dmyToYmd(str) {
|
||||
if (!str) return "";
|
||||
const parts = str.replace(/_/g, '.').split('.');
|
||||
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function ymdToDmy(str) {
|
||||
if (!str) return "";
|
||||
const parts = str.split('-');
|
||||
if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom = '', dateTo = '') {
|
||||
const modal = document.getElementById("remote-worker-modal");
|
||||
const titleEl = document.getElementById("remote-modal-title");
|
||||
const modeInput = document.getElementById("rw-mode");
|
||||
const fioInput = document.getElementById("rw-fio");
|
||||
const deptInput = document.getElementById("rw-dept");
|
||||
const fromInput = document.getElementById("rw-date-from");
|
||||
const toInput = document.getElementById("rw-date-to");
|
||||
const errEl = document.getElementById("rw-error");
|
||||
|
||||
errEl.classList.add("hidden");
|
||||
modeInput.value = mode;
|
||||
|
||||
if (mode === 'EDIT') {
|
||||
titleEl.innerHTML = `<i class="fa-solid fa-pen-to-square text-emerald-600"></i><span>Изменение сроков удаленки</span>`;
|
||||
fioInput.value = fio;
|
||||
fioInput.readOnly = true;
|
||||
fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||||
deptInput.value = dept || "Все";
|
||||
deptInput.readOnly = true;
|
||||
deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||||
fromInput.value = dmyToYmd(dateFrom);
|
||||
toInput.value = dmyToYmd(dateTo);
|
||||
} else {
|
||||
titleEl.innerHTML = `<i class="fa-solid fa-house-laptop text-emerald-600"></i><span>Добавление удаленщика</span>`;
|
||||
fioInput.value = "";
|
||||
fioInput.readOnly = false;
|
||||
fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||||
deptInput.value = "Все";
|
||||
deptInput.readOnly = false;
|
||||
deptInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
fromInput.value = today;
|
||||
toInput.value = "";
|
||||
}
|
||||
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeRemoteWorkerModal() {
|
||||
document.getElementById("remote-worker-modal").classList.add("hidden");
|
||||
}
|
||||
|
||||
async function handleRemoteWorkerSubmit(e) {
|
||||
e.preventDefault();
|
||||
const mode = document.getElementById("rw-mode").value;
|
||||
const fio = document.getElementById("rw-fio").value.trim();
|
||||
const dept = document.getElementById("rw-dept").value.trim() || "Все";
|
||||
const fromVal = ymdToDmy(document.getElementById("rw-date-from").value);
|
||||
const toVal = ymdToDmy(document.getElementById("rw-date-to").value);
|
||||
const errEl = document.getElementById("rw-error");
|
||||
errEl.classList.add("hidden");
|
||||
|
||||
const method = (mode === 'EDIT') ? "PUT" : "POST";
|
||||
const payload = (mode === 'EDIT')
|
||||
? { fio: fio, date_from: fromVal, date_to: toVal }
|
||||
: { fio: fio, department: dept, reason: "Удаленная работа", date_from: fromVal, date_to: toVal };
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/remote-workers", {
|
||||
method: method,
|
||||
headers: AuthManager.getAuthHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeRemoteWorkerModal();
|
||||
if (window.SidebarManager) SidebarManager.renderContent();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
errEl.innerText = err.detail || "Ошибка сохранения";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.innerText = "Ошибка соединения с сервером";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (window.AuthManager && AuthManager.isAuthenticated()) {
|
||||
updateUIState();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Глобальная Drag-and-Drop зона на весь экран -->
|
||||
<!-- ГЛОБАЛЬНАЯ 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">
|
||||
@@ -706,5 +161,14 @@
|
||||
<div class="text-xs text-slate-500 font-medium">PDF-документы, сканы, отчеты или изображения</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- СКРИПТЫ -->
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* ===============================================================================
|
||||
* FILE: modules/web_api/static/js/app.js
|
||||
* ROLE: Главная точка входа UI: динамическая загрузка модальных окон,
|
||||
* маршрутизация авторизации, управление профилем и глобальный стейт.
|
||||
* ===============================================================================
|
||||
*/
|
||||
|
||||
const AUTH_TOKEN_KEY = "scud_api_auth_token";
|
||||
const SESSION_ID = "web_session_main";
|
||||
const STORAGE_KEY = "scud_chat_input_history";
|
||||
@@ -10,9 +18,360 @@ let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
|
||||
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
let historyIndex = -1;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const userInputEl = document.getElementById("user-input");
|
||||
// ============================================================================
|
||||
// 1. ДИНАМИЧЕСКАЯ ЗАГРУЗКА МОДАЛЬНЫХ ОКОН
|
||||
// ============================================================================
|
||||
async function loadModals() {
|
||||
const modalFiles = [
|
||||
'remote_worker_modal.html',
|
||||
'manual_absence_modal.html',
|
||||
'exception_modal.html',
|
||||
'auth_modal.html',
|
||||
'profile_modal.html',
|
||||
'admin_modal.html'
|
||||
];
|
||||
|
||||
const container = document.getElementById('modals-container');
|
||||
if (!container) return;
|
||||
|
||||
for (const file of modalFiles) {
|
||||
try {
|
||||
const res = await fetch(`/static/modals/${file}?v=2.5.8`);
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Modals] Ошибка загрузки ${file}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. УПРАВЛЕНИЕ АВТОРИЗАЦИЕЙ И ПРОФИЛЕМ В UI
|
||||
// ============================================================================
|
||||
function showAuthModal() {
|
||||
const modal = document.getElementById("auth-modal");
|
||||
if (modal) modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideAuthModal() {
|
||||
const modal = document.getElementById("auth-modal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
}
|
||||
|
||||
function updateUIState() {
|
||||
const nameEl = document.getElementById("user-display-name");
|
||||
const roleEl = document.getElementById("user-display-role");
|
||||
const adminBtn = document.getElementById("admin-panel-btn");
|
||||
|
||||
if (nameEl) nameEl.innerText = AuthManager.getFullName();
|
||||
if (roleEl) roleEl.innerText = AuthManager.isAdmin() ? "Администратор" : "Оператор";
|
||||
|
||||
if (adminBtn) {
|
||||
if (AuthManager.isAdmin()) {
|
||||
adminBtn.classList.remove("hidden");
|
||||
} else {
|
||||
adminBtn.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginSubmit(e) {
|
||||
e.preventDefault();
|
||||
const uInput = document.getElementById("auth-username");
|
||||
const pInput = document.getElementById("auth-password");
|
||||
const errEl = document.getElementById("auth-error");
|
||||
if (errEl) errEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: uInput.value.trim(),
|
||||
password: pInput.value
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
if (window.SidebarManager) SidebarManager.setHub('TASKS');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
if (errEl) {
|
||||
errEl.innerText = err.detail || "Неверный логин или пароль";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (errEl) {
|
||||
errEl.innerText = "Ошибка соединения с сервером";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openProfileModal() {
|
||||
const modal = document.getElementById("profile-modal");
|
||||
if (modal) modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeProfileModal() {
|
||||
const modal = document.getElementById("profile-modal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function handleChangePassword(e) {
|
||||
e.preventDefault();
|
||||
const oldP = document.getElementById("old-pass").value;
|
||||
const newP = document.getElementById("new-pass").value;
|
||||
const errEl = document.getElementById("pass-error");
|
||||
if (errEl) errEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: AuthManager.getAuthHeaders(),
|
||||
body: JSON.stringify({ old_password: oldP, new_password: newP })
|
||||
});
|
||||
if (res.ok) {
|
||||
alert("Пароль успешно изменен");
|
||||
closeProfileModal();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
if (errEl) {
|
||||
errEl.innerText = err.detail || "Ошибка изменения пароля";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (errEl) {
|
||||
errEl.innerText = "Ошибка сети";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. АДМИНИСТРИРОВАНИЕ ПОЛЬЗОВАТЕЛЕЙ
|
||||
// ============================================================================
|
||||
function openAdminModal() {
|
||||
const modal = document.getElementById("admin-modal");
|
||||
if (modal) {
|
||||
modal.classList.remove("hidden");
|
||||
loadAdminUsers();
|
||||
}
|
||||
}
|
||||
|
||||
function closeAdminModal() {
|
||||
const modal = document.getElementById("admin-modal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function loadAdminUsers() {
|
||||
const listEl = document.getElementById("admin-users-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>`;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/users", { headers: AuthManager.getAuthHeaders() });
|
||||
if (res.ok) {
|
||||
const users = await res.json();
|
||||
listEl.innerHTML = users.map(u => `
|
||||
<div class="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs">
|
||||
<div>
|
||||
<div class="font-bold text-slate-800">${escapeHtml(u.full_name || u.username)} <span class="text-slate-400 font-mono text-[10px]">(${escapeHtml(u.username)})</span></div>
|
||||
<div class="text-[10px] ${u.is_admin ? 'text-indigo-600 font-bold' : 'text-slate-400'}">${u.is_admin ? 'Администратор' : 'Оператор'}</div>
|
||||
</div>
|
||||
<button onclick="deleteAdminUser(${u.id}, '${escapeHtml(u.username)}')" class="text-slate-400 hover:text-rose-600 p-1.5" title="Удалить">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка загрузки пользователей</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка сети</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser(e) {
|
||||
e.preventDefault();
|
||||
const u = document.getElementById("new-user-username").value;
|
||||
const p = document.getElementById("new-user-password").value;
|
||||
const f = document.getElementById("new-user-fullname").value;
|
||||
const a = document.getElementById("new-user-admin").checked;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/users", {
|
||||
method: "POST",
|
||||
headers: AuthManager.getAuthHeaders(),
|
||||
body: JSON.stringify({ username: u, password: p, full_name: f, is_admin: a })
|
||||
});
|
||||
if (res.ok) {
|
||||
document.getElementById("new-user-username").value = "";
|
||||
document.getElementById("new-user-password").value = "";
|
||||
document.getElementById("new-user-fullname").value = "";
|
||||
document.getElementById("new-user-admin").checked = false;
|
||||
loadAdminUsers();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.detail || "Ошибка создания пользователя");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Ошибка сети");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAdminUser(id, username) {
|
||||
if (!confirm(`Удалить пользователя ${username}?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: AuthManager.getAuthHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
loadAdminUsers();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.detail || "Ошибка удаления");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Ошибка сети");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. ВСПОМОГАТЕЛЬНЫЕ ФОРМАТЕРЫ И МОДАЛКА УДАЛЕНЩИКОВ
|
||||
// ============================================================================
|
||||
function dmyToYmd(str) {
|
||||
if (!str) return "";
|
||||
const parts = str.replace(/_/g, '.').split('.');
|
||||
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function ymdToDmy(str) {
|
||||
if (!str) return "";
|
||||
const parts = str.split('-');
|
||||
if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom = '', dateTo = '') {
|
||||
const modal = document.getElementById("remote-worker-modal");
|
||||
const titleEl = document.getElementById("remote-modal-title");
|
||||
const modeInput = document.getElementById("rw-mode");
|
||||
const fioInput = document.getElementById("rw-fio");
|
||||
const deptInput = document.getElementById("rw-dept");
|
||||
const fromInput = document.getElementById("rw-date-from");
|
||||
const toInput = document.getElementById("rw-date-to");
|
||||
const errEl = document.getElementById("rw-error");
|
||||
|
||||
if (!modal) return;
|
||||
if (errEl) errEl.classList.add("hidden");
|
||||
if (modeInput) modeInput.value = mode;
|
||||
|
||||
if (mode === 'EDIT') {
|
||||
if (titleEl) titleEl.innerHTML = `<i class="fa-solid fa-pen-to-square text-emerald-600"></i><span>Изменение сроков удаленки</span>`;
|
||||
if (fioInput) {
|
||||
fioInput.value = fio;
|
||||
fioInput.readOnly = true;
|
||||
fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||||
}
|
||||
if (deptInput) {
|
||||
deptInput.value = dept || "Все";
|
||||
deptInput.readOnly = true;
|
||||
deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||||
}
|
||||
if (fromInput) fromInput.value = dmyToYmd(dateFrom);
|
||||
if (toInput) toInput.value = dmyToYmd(dateTo);
|
||||
} else {
|
||||
if (titleEl) titleEl.innerHTML = `<i class="fa-solid fa-house-laptop text-emerald-600"></i><span>Добавление удаленщика</span>`;
|
||||
if (fioInput) {
|
||||
fioInput.value = "";
|
||||
fioInput.readOnly = false;
|
||||
fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||||
}
|
||||
if (deptInput) {
|
||||
deptInput.value = "Все";
|
||||
deptInput.readOnly = false;
|
||||
deptInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||||
}
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (fromInput) fromInput.value = today;
|
||||
if (toInput) toInput.value = "";
|
||||
}
|
||||
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeRemoteWorkerModal() {
|
||||
const modal = document.getElementById("remote-worker-modal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function handleRemoteWorkerSubmit(e) {
|
||||
e.preventDefault();
|
||||
const mode = document.getElementById("rw-mode").value;
|
||||
const fio = document.getElementById("rw-fio").value.trim();
|
||||
const dept = document.getElementById("rw-dept").value.trim() || "Все";
|
||||
const fromVal = ymdToDmy(document.getElementById("rw-date-from").value);
|
||||
const toVal = ymdToDmy(document.getElementById("rw-date-to").value);
|
||||
const errEl = document.getElementById("rw-error");
|
||||
if (errEl) errEl.classList.add("hidden");
|
||||
|
||||
const method = (mode === 'EDIT') ? "PUT" : "POST";
|
||||
const payload = (mode === 'EDIT')
|
||||
? { fio: fio, date_from: fromVal, date_to: toVal }
|
||||
: { fio: fio, department: dept, reason: "Удаленная работа", date_from: fromVal, date_to: toVal };
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/remote-workers", {
|
||||
method: method,
|
||||
headers: AuthManager.getAuthHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeRemoteWorkerModal();
|
||||
if (window.SidebarManager) SidebarManager.renderContent();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
if (errEl) {
|
||||
errEl.innerText = err.detail || "Ошибка сохранения";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (errEl) {
|
||||
errEl.innerText = "Ошибка соединения с сервером";
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 5. ИНИЦИАЛИЗАЦИЯ ПРИЛОЖЕНИЯ
|
||||
// ============================================================================
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// 1. Асинхронная подгрузка модальных окон
|
||||
await loadModals();
|
||||
|
||||
// 2. Инициализация автокомплита исключений после монтирования разметки
|
||||
const excInput = document.getElementById("exception-value-input");
|
||||
const excBox = document.getElementById("exception-suggestions");
|
||||
if (excInput && excBox && typeof setupStaffAutocomplete === "function") {
|
||||
setupStaffAutocomplete(excInput, "exception-suggestions");
|
||||
}
|
||||
|
||||
// 3. Авто-высота поля ввода команд
|
||||
const userInputEl = document.getElementById("user-input");
|
||||
if (userInputEl) {
|
||||
userInputEl.addEventListener("input", function() {
|
||||
this.style.height = "24px";
|
||||
@@ -21,15 +380,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Проверка сессии пользователя
|
||||
if (IS_GUEST) {
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
} else if (API_TOKEN) {
|
||||
} else if (AuthManager && AuthManager.isAuthenticated()) {
|
||||
hideAuthModal();
|
||||
updateUIState();
|
||||
if (typeof loadTasks === "function") {
|
||||
loadTasks();
|
||||
}
|
||||
if (window.SidebarManager) {
|
||||
SidebarManager.init();
|
||||
}
|
||||
} else {
|
||||
showAuthModal();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<div id="admin-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 max-w-lg w-full p-6 flex flex-col gap-4 max-h-[85vh]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление учетными записями
|
||||
</h3>
|
||||
<button onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
|
||||
<form onsubmit="handleCreateUser(event)" class="p-3 bg-slate-50 border border-slate-200 rounded-xl flex flex-col gap-2">
|
||||
<span class="text-xs font-bold text-slate-700">Создать нового пользователя:</span>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input type="text" id="new-user-username" placeholder="Логин" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||||
<input type="password" id="new-user-password" placeholder="Пароль" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||||
</div>
|
||||
<input type="text" id="new-user-fullname" placeholder="ФИО" class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer">
|
||||
<input type="checkbox" id="new-user-admin" class="rounded border-slate-300 text-indigo-600" />
|
||||
<span>Права администратора</span>
|
||||
</label>
|
||||
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex-1 overflow-y-auto flex flex-col gap-1.5" id="admin-users-list">
|
||||
<div class="text-center py-4 text-xs text-slate-400">Загрузка пользователей...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<div id="auth-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 max-w-sm w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center font-bold text-lg shadow">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-sm font-bold text-slate-800">Авторизация в системе</h2>
|
||||
<p class="text-[11px] text-slate-400">SCUD Orion AI Security Access</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="auth-form" onsubmit="handleLoginSubmit(event)" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Имя пользователя:</label>
|
||||
<input type="text" id="auth-username" 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" placeholder="Логин" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Пароль:</label>
|
||||
<input type="password" id="auth-password" 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" placeholder="••••••••" />
|
||||
</div>
|
||||
<div id="auth-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
<button type="submit" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg text-xs shadow transition mt-1">
|
||||
Войти в систему
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
<div id="exception-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 max-w-md w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="exception-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<i class="fa-solid fa-user-shield text-indigo-600"></i>
|
||||
<span id="exception-modal-header-text">Добавление в реестр</span>
|
||||
</h3>
|
||||
<button type="button" onclick="closeExceptionModal()" class="text-slate-400 hover:text-slate-600">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="exception-modal-form" onsubmit="submitExceptionModalForm(event)" class="flex flex-col gap-3">
|
||||
<input type="hidden" id="exception-category-input" value="" />
|
||||
|
||||
<div class="relative">
|
||||
<label id="exception-value-label" class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
|
||||
<input type="text" id="exception-value-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" />
|
||||
<div id="exception-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>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Примечание / основание (опционально):</label>
|
||||
<input type="text" id="exception-comment-input" 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" />
|
||||
</div>
|
||||
|
||||
<div id="exception-error-msg" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||||
<button type="button" onclick="closeExceptionModal()"
|
||||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" id="exception-submit-btn"
|
||||
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-check text-xs"></i>
|
||||
<span>Добавить</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<div id="manual-absence-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 max-w-md w-full p-6 flex flex-col gap-4">
|
||||
<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>
|
||||
</h3>
|
||||
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<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" />
|
||||
<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>
|
||||
</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"
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
|
||||
<input type="date" id="manual-absence-end-date"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<div id="profile-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 max-w-sm w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
|
||||
</h3>
|
||||
<button onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
<form onsubmit="handleChangePassword(event)" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Текущий пароль:</label>
|
||||
<input type="password" id="old-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Новый пароль (мин. 4 симв.):</label>
|
||||
<input type="password" id="new-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
<div id="pass-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
<div class="flex items-center justify-end gap-2 mt-2">
|
||||
<button type="button" onclick="closeProfileModal()" class="px-3 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium">Отмена</button>
|
||||
<button type="submit" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
<div id="remote-worker-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 max-w-md w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="remote-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<i class="fa-solid fa-house-laptop text-emerald-600"></i>
|
||||
<span>Параметры удаленной работы</span>
|
||||
</h3>
|
||||
<button type="button" onclick="closeRemoteWorkerModal()" class="text-slate-400 hover:text-slate-600">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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="Например: Иванов Иван Иванович"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Подразделение:</label>
|
||||
<input type="text" id="rw-dept" 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>
|
||||
|
||||
<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="rw-date-from"
|
||||
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 text-slate-700" />
|
||||
<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="rw-date-to"
|
||||
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 text-slate-700" />
|
||||
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = бессрочно</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rw-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||||
<button type="button" onclick="closeRemoteWorkerModal()"
|
||||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" id="rw-submit-btn"
|
||||
class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,5 +5,5 @@ cd /home/puh/projects/scud_ai
|
||||
mkdir -p /home/puh/projects/scud_ai/logs
|
||||
|
||||
echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log
|
||||
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py --use-existing-snapshot >> /home/puh/projects/scud_ai/logs/cron_reports.log 2>&1
|
||||
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py --skip-export >> /home/puh/projects/scud_ai/logs/cron_reports.log 2>&1
|
||||
echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log
|
||||
+8
-47
@@ -7,6 +7,8 @@ from datetime import datetime
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from modules.web_api.llm.db.db_chat import db_clear_all_chat_context
|
||||
from core.database import dump_database_to_excel
|
||||
from core.repositories.scud_repo import get_building_presence
|
||||
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR, EXCEPTIONS_PATH, normalize_fio
|
||||
from core.database import (
|
||||
@@ -297,15 +299,8 @@ def print_rules():
|
||||
|
||||
def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
|
||||
"""Дампит всю базу SQLite во многостраничный Excel."""
|
||||
out_path = os.path.join(OUTPUT_DIR, out_filename)
|
||||
print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
|
||||
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
|
||||
for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks', 'exceptions_registry']:
|
||||
try:
|
||||
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
|
||||
df.to_excel(writer, sheet_name=table[:31], index=False)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"\n[🔄] Создание полного дампа БД в файл: {out_filename} ...")
|
||||
out_path = dump_database_to_excel(out_filename)
|
||||
print(f"[✓] Дамп успешно сохранен: {out_path}\n")
|
||||
|
||||
|
||||
@@ -394,44 +389,10 @@ def print_chat_messages(session_id=None, limit=50):
|
||||
|
||||
|
||||
def purge_chat_context(session_id=None, purge_all=False):
|
||||
"""
|
||||
Очистка контекста сообщений:
|
||||
- По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий.
|
||||
- purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии.
|
||||
"""
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if purge_all:
|
||||
if session_id:
|
||||
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
|
||||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||||
else:
|
||||
cursor.execute("DELETE FROM chat_messages")
|
||||
cursor.execute("DELETE FROM session_states")
|
||||
deleted_msgs = cursor.rowcount
|
||||
conn.commit()
|
||||
print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n")
|
||||
return
|
||||
|
||||
query = """
|
||||
DELETE FROM chat_messages
|
||||
WHERE is_ephemeral = 1
|
||||
OR content LIKE '%Предпросмотр изменений%'
|
||||
OR content LIKE '%Удален пункт:%'
|
||||
OR content LIKE '%добавлен пункт:%'
|
||||
"""
|
||||
if session_id:
|
||||
cursor.execute(query + " AND session_id = ?", (session_id,))
|
||||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||||
else:
|
||||
cursor.execute(query)
|
||||
cursor.execute("DELETE FROM session_states")
|
||||
|
||||
deleted_msgs = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
|
||||
"""Очистка контекста сообщений через репозиторий чата."""
|
||||
deleted_msgs = db_clear_all_chat_context(session_id=session_id, purge_all=purge_all)
|
||||
mode = "Полная" if purge_all else "Умная"
|
||||
print(f"\n[✓] {mode} зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
|
||||
|
||||
|
||||
# ⭐️ Новые функции управления исключениями (Exceptions & Whitelist)
|
||||
|
||||
@@ -42,12 +42,20 @@ TARGET_FILES = [
|
||||
"services/knowledge_base.py",
|
||||
"services/knowledge/service.py",
|
||||
|
||||
# Модули сборки Сводки и Отчета
|
||||
# Модули генерации отчетов Excel
|
||||
"services/reports/styles.py",
|
||||
"services/reports/calculators.py",
|
||||
"services/reports/svodka_builder.py",
|
||||
"services/reports/otchet_builder.py",
|
||||
"services/reports/raw_scud_builder.py",
|
||||
|
||||
# Модули сборки Сводки, Отчета и запросы
|
||||
"services/scud_etl/pipeline.py",
|
||||
"services/scud_etl/merger.py",
|
||||
"services/scud_etl/svodka_generator.py",
|
||||
"services/scud_etl/otchet_generator.py",
|
||||
"services/scud_etl/anomaly_detector.py",
|
||||
"services/scud_etl/sql_queries.py",
|
||||
"services/snapshots/service.py",
|
||||
"services/tasks/repository.py",
|
||||
"services/tasks/service.py"
|
||||
|
||||
+13
-503
@@ -1,509 +1,19 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/excel_exporter.py
|
||||
ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter.
|
||||
Корректный расчет часов удаленщиков и исключение лишних списков.
|
||||
ROLE: Единый фасад генераторов Excel-отчетов (обратная совместимость).
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import time
|
||||
import pandas as pd
|
||||
import xlsxwriter
|
||||
from datetime import datetime, timedelta
|
||||
from xlsxwriter.exceptions import FileCreateError
|
||||
from config import REPORTS_DIR
|
||||
|
||||
MONTHS_RU_GENITIVE = {
|
||||
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
|
||||
5: "мая", 6: "июня", 7: "июля", 8: "августа",
|
||||
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
|
||||
}
|
||||
|
||||
MONTHS_RU_NOMINATIVE = {
|
||||
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
|
||||
5: "май", 6: "июнь", 7: "июль", 8: "август",
|
||||
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь"
|
||||
}
|
||||
|
||||
|
||||
def format_date_ru(date_str):
|
||||
date_clean = str(date_str).replace('_', '.')
|
||||
try:
|
||||
dt = datetime.strptime(date_clean, "%d.%m.%Y")
|
||||
return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}"
|
||||
except Exception:
|
||||
return date_str
|
||||
|
||||
|
||||
def get_dated_reports_dir(date_str):
|
||||
date_clean = str(date_str).replace('_', '.')
|
||||
try:
|
||||
dt = datetime.strptime(date_clean, "%d.%m.%Y")
|
||||
year_str = str(dt.year)
|
||||
month_name = MONTHS_RU_NOMINATIVE[dt.month]
|
||||
except Exception:
|
||||
now = datetime.now()
|
||||
year_str = str(now.year)
|
||||
month_name = MONTHS_RU_NOMINATIVE[now.month]
|
||||
|
||||
target_dir = os.path.join(REPORTS_DIR, year_str, month_name)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
return target_dir
|
||||
|
||||
|
||||
def safe_close_workbook(wb, output_path, target_dir, filename):
|
||||
try:
|
||||
wb.close()
|
||||
print(f"[✓] Успешно сохранен: {output_path}")
|
||||
return output_path
|
||||
except (FileCreateError, OSError, PermissionError):
|
||||
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
|
||||
alt_path = os.path.join(target_dir, alt_filename)
|
||||
try:
|
||||
wb.filename = alt_path
|
||||
wb._store_workbook()
|
||||
print(f"[⚠️] Исходный файл открыт в Excel! Сохранено как: {alt_path}")
|
||||
return alt_path
|
||||
except Exception as e:
|
||||
print(f"[❌] Ошибка сохранения даже резервного файла: {e}")
|
||||
return output_path
|
||||
|
||||
|
||||
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
|
||||
"""
|
||||
Расчет отклонения от нормы.
|
||||
Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение.
|
||||
"""
|
||||
reason_clean = str(reason).strip().lower() if pd.notna(reason) else ""
|
||||
is_remote = "удален" in reason_clean or "дистанцион" in reason_clean
|
||||
|
||||
has_building_time = isinstance(time_in_building_str, str) and time_in_building_str not in ['00:00', '0', '', 'None', 'nan', 'NaN']
|
||||
|
||||
# Если есть уважительная причина (больничный, отпуск, командировка и т.д.) не удаленка
|
||||
if reason_clean != "" and not is_remote:
|
||||
return "0:00"
|
||||
|
||||
# Если удаленщик работал исключительно из дома (00:00 в здании)
|
||||
if is_remote and not has_building_time:
|
||||
return "0:00"
|
||||
|
||||
# Если сотрудника не было в здании и нет уважительной причины
|
||||
if not has_building_time:
|
||||
return f"-{norm_hours}:00"
|
||||
|
||||
try:
|
||||
parts = time_in_building_str.strip().split(':')
|
||||
hh = int(parts[0])
|
||||
mm = int(parts[1]) if len(parts) > 1 else 0
|
||||
total_in_building_minutes = hh * 60 + mm
|
||||
|
||||
if total_in_building_minutes == 0:
|
||||
return "0:00" if is_remote else f"-{norm_hours}:00"
|
||||
|
||||
work_minutes = max(0, total_in_building_minutes - lunch_minutes)
|
||||
norm_minutes = norm_hours * 60
|
||||
diff = work_minutes - norm_minutes
|
||||
|
||||
if diff == 0:
|
||||
return "0:00"
|
||||
|
||||
sign = "-" if diff < 0 else ""
|
||||
abs_diff = abs(diff)
|
||||
res_hh = abs_diff // 60
|
||||
res_mm = abs_diff % 60
|
||||
|
||||
return f"{sign}{res_hh}:{res_mm:02d}"
|
||||
except Exception:
|
||||
return "0:00" if is_remote else f"-{norm_hours}:00"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. ЕЖЕДНЕВНАЯ СВОДКА НА СЕГОДНЯ
|
||||
# =============================================================================
|
||||
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
|
||||
date_clean = str(date_str).replace('_', '.')
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_clean)} сводка.xlsx"
|
||||
|
||||
target_dir = get_dated_reports_dir(date_clean)
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
|
||||
wb = xlsxwriter.Workbook(output_path)
|
||||
ws = wb.add_worksheet("Лист_1")
|
||||
|
||||
ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False)
|
||||
|
||||
def make_fmt(bg_color=None, bold=False, align="left", wrap=False):
|
||||
d = {
|
||||
'font_name': 'Calibri',
|
||||
'font_size': 11,
|
||||
'bold': bold,
|
||||
'align': align,
|
||||
'valign': 'vcenter',
|
||||
'border': 1,
|
||||
'border_color': '#D3D3D3',
|
||||
'text_wrap': wrap
|
||||
}
|
||||
if bg_color:
|
||||
d['bg_color'] = bg_color
|
||||
return wb.add_format(d)
|
||||
|
||||
fmt_hdr_l = make_fmt(bg_color='#D9E1F2', bold=True, align="left")
|
||||
fmt_hdr_r = make_fmt(bg_color='#D9E1F2', bold=True, align="right")
|
||||
fmt_tot_l = make_fmt(bg_color='#F2F2F2', bold=True, align="left")
|
||||
fmt_tot_r = make_fmt(bg_color='#F2F2F2', bold=True, align="right")
|
||||
fmt_empty = make_fmt()
|
||||
|
||||
ws.set_row(0, 20)
|
||||
ws.write(0, 0, "Сводка на", fmt_hdr_l)
|
||||
ws.write(0, 1, date_clean, fmt_hdr_r)
|
||||
|
||||
ws.set_row(1, 20)
|
||||
ws.write(1, 0, "", fmt_empty)
|
||||
ws.write(1, 1, "", fmt_empty)
|
||||
|
||||
ws.set_row(2, 20)
|
||||
ws.write(2, 0, "По списку", fmt_tot_l)
|
||||
ws.write(2, 1, len(merged_df), fmt_tot_r)
|
||||
|
||||
current_row = 3
|
||||
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
|
||||
is_exc = merged_df.get('is_excluded', False) == True
|
||||
|
||||
# 1. Неизвестно (Раскрыто по умолчанию)
|
||||
unexplained = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
||||
(~is_no_pass) &
|
||||
(~is_exc)
|
||||
]
|
||||
fmt_unexp_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left")
|
||||
fmt_unexp_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right")
|
||||
fmt_unexp_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left")
|
||||
fmt_unexp_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "неизвестно", fmt_unexp_hl)
|
||||
ws.write(current_row, 1, len(unexplained), fmt_unexp_hr)
|
||||
current_row += 1
|
||||
|
||||
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
|
||||
ws.write(current_row, 0, fio, fmt_unexp_rl)
|
||||
ws.write(current_row, 1, "", fmt_unexp_rr)
|
||||
current_row += 1
|
||||
|
||||
# 2. Нет пропуска (Раскрыто по умолчанию)
|
||||
no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
|
||||
fmt_np_hl = make_fmt(bg_color='#E1F5FE', bold=True, align="left")
|
||||
fmt_np_hr = make_fmt(bg_color='#E1F5FE', bold=True, align="right")
|
||||
fmt_np_rl = make_fmt(bg_color='#E1F5FE', bold=False, align="left")
|
||||
fmt_np_rr = make_fmt(bg_color='#E1F5FE', bold=False, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "Нет пропуска", fmt_np_hl)
|
||||
ws.write(current_row, 1, len(no_pass_df), fmt_np_hr)
|
||||
current_row += 1
|
||||
|
||||
if not no_pass_df.empty:
|
||||
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
|
||||
ws.write(current_row, 0, fio, fmt_np_rl)
|
||||
ws.write(current_row, 1, "", fmt_np_rr)
|
||||
current_row += 1
|
||||
|
||||
# 3. Официальные отсутствия
|
||||
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
|
||||
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
|
||||
|
||||
absent_only = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
|
||||
(~is_remote_reason)
|
||||
]
|
||||
absent_groups = absent_only.groupby('Вид_отсутствия')
|
||||
pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF']
|
||||
|
||||
for idx_cat, (cat_name, group) in enumerate(absent_groups):
|
||||
hex_c = pastels[idx_cat % len(pastels)]
|
||||
fmt_cat_hl = make_fmt(bg_color=hex_c, bold=True, align="left")
|
||||
fmt_cat_hr = make_fmt(bg_color=hex_c, bold=True, align="right")
|
||||
fmt_cat_rl = make_fmt(bg_color=hex_c, bold=False, align="left")
|
||||
fmt_cat_rr = make_fmt(bg_color=hex_c, bold=False, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, cat_name, fmt_cat_hl)
|
||||
ws.write(current_row, 1, len(group), fmt_cat_hr)
|
||||
current_row += 1
|
||||
|
||||
is_other_category = (str(cat_name).strip().lower() == "иное")
|
||||
|
||||
for _, row in group.sort_values(by='Сотрудник').iterrows():
|
||||
fio = row.get('Сотрудник', '')
|
||||
# Если категория "Иное" — берем детальную причину из manual_absences / detailed_reason
|
||||
detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else ""
|
||||
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
|
||||
ws.write(current_row, 0, fio, fmt_cat_rl)
|
||||
ws.write(current_row, 1, detail_val, fmt_cat_rr)
|
||||
current_row += 1
|
||||
|
||||
# 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок)
|
||||
exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))]
|
||||
present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
|
||||
|
||||
total_present_count = len(present_scud) + len(exc_without_doc)
|
||||
|
||||
fmt_pres_hl = make_fmt(bg_color='#E2EFDA', bold=True, align="left")
|
||||
fmt_pres_hr = make_fmt(bg_color='#E2EFDA', bold=True, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "Итого на работе", fmt_pres_hl)
|
||||
ws.write(current_row, 1, total_present_count, fmt_pres_hr)
|
||||
current_row += 1
|
||||
|
||||
# 5. Удаленная работа (Свернуто)
|
||||
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
|
||||
fmt_rem_hl = make_fmt(bg_color='#E8F8F5', bold=True, align="left")
|
||||
fmt_rem_hr = make_fmt(bg_color='#E8F8F5', bold=True, align="right")
|
||||
fmt_rem_rl = make_fmt(bg_color='#E8F8F5', bold=False, align="left")
|
||||
fmt_rem_rr = make_fmt(bg_color='#E8F8F5', bold=False, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl)
|
||||
ws.write(current_row, 1, len(remote_home), fmt_rem_hr)
|
||||
current_row += 1
|
||||
|
||||
if not remote_home.empty:
|
||||
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
|
||||
ws.write(current_row, 0, fio, fmt_rem_rl)
|
||||
ws.write(current_row, 1, "", fmt_rem_rr)
|
||||
current_row += 1
|
||||
|
||||
# 6. Аномалии СКУД и 1С (Свернуто)
|
||||
anomalies = merged_df[
|
||||
(~is_exc) & (
|
||||
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
|
||||
(~is_remote_reason) &
|
||||
(~reason_clean.str.contains('командировк', na=False))) |
|
||||
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
|
||||
)
|
||||
]
|
||||
fmt_anom_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left")
|
||||
fmt_anom_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right")
|
||||
fmt_anom_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left")
|
||||
fmt_anom_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="left", wrap=True)
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl)
|
||||
ws.write(current_row, 1, len(anomalies), fmt_anom_hr)
|
||||
current_row += 1
|
||||
|
||||
chars_per_line_b = 30
|
||||
if not anomalies.empty:
|
||||
for _, row in anomalies.iterrows():
|
||||
fio = row.get('Сотрудник', '')
|
||||
reason = row.get('Вид_отсутствия', '')
|
||||
anom_flag = row.get('anomaly_flag', 'NONE')
|
||||
|
||||
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
|
||||
first_act = row.get('Первая_активность', '—')
|
||||
reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})"
|
||||
else:
|
||||
reason_text = f"В 1С: {reason}"
|
||||
|
||||
lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
|
||||
row_h = max(lines_count * 18, 20)
|
||||
|
||||
ws.set_row(current_row, row_h, None, {'level': 1, 'hidden': True, 'collapsed': True})
|
||||
ws.write(current_row, 0, fio, fmt_anom_rl)
|
||||
ws.write(current_row, 1, reason_text, fmt_anom_rr)
|
||||
current_row += 1
|
||||
|
||||
ws.set_column(0, 0, 45)
|
||||
ws.set_column(1, 1, 38)
|
||||
|
||||
safe_close_workbook(wb, output_path, target_dir, filename)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. ДЕТАЛЬНЫЙ СУТОЧНЫЙ ОТЧЕТ ЗА ВЧЕРА
|
||||
# =============================================================================
|
||||
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
|
||||
date_clean = str(date_str).replace('_', '.')
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_clean)} отчет.xlsx"
|
||||
|
||||
if merged_df is not None and not merged_df.empty:
|
||||
df_export = merged_df[merged_df.get('is_excluded', False) == False].copy()
|
||||
else:
|
||||
df_export = pd.DataFrame()
|
||||
|
||||
target_dir = get_dated_reports_dir(date_clean)
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
|
||||
wb = xlsxwriter.Workbook(output_path)
|
||||
ws = wb.add_worksheet("Детальный_отчет")
|
||||
|
||||
def make_fmt(bg_color=None, bold=False, align="left", wrap=False):
|
||||
d = {
|
||||
'font_name': 'Arial',
|
||||
'font_size': 10,
|
||||
'bold': bold,
|
||||
'align': align,
|
||||
'valign': 'vcenter',
|
||||
'border': 1,
|
||||
'border_color': '#D3D3D3',
|
||||
'text_wrap': wrap
|
||||
}
|
||||
if bg_color:
|
||||
d['bg_color'] = bg_color
|
||||
return wb.add_format(d)
|
||||
|
||||
fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True})
|
||||
ws.write(1, 1, "Дата:", fmt_date_lbl)
|
||||
ws.write(1, 3, date_clean, fmt_date_lbl)
|
||||
|
||||
headers = [
|
||||
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
|
||||
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
|
||||
]
|
||||
fmt_hdr = make_fmt(bg_color='#D9E1F2', bold=True, align="center", wrap=True)
|
||||
ws.set_row(3, 26)
|
||||
for col_idx, h_text in enumerate(headers):
|
||||
ws.write(3, col_idx, h_text, fmt_hdr)
|
||||
|
||||
start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
|
||||
end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
|
||||
hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании'
|
||||
|
||||
chars_per_line_h = 24
|
||||
|
||||
for idx, row in df_export.reset_index(drop=True).iterrows():
|
||||
row_num = 4 + idx
|
||||
is_present = row.get('Пришел', False)
|
||||
absence_reason = row.get('Вид_отсутствия', '')
|
||||
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
|
||||
|
||||
in_val = str(row.get(start_col, 'Нет входа')).strip()
|
||||
out_val = str(row.get(end_col, 'Нет выхода')).strip()
|
||||
in_building_str = str(row.get(hours_col, '00:00'))
|
||||
first_act_val = str(row.get('Первая_активность', '—')).strip()
|
||||
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
|
||||
|
||||
# Автозакрытие отключено по согласованию с ОК: сохраняем факт отсутствия выхода
|
||||
deviation_val = calculate_deviation(
|
||||
in_building_str,
|
||||
reason=absence_reason if has_reason else "",
|
||||
norm_hours=8,
|
||||
lunch_minutes=30
|
||||
)
|
||||
|
||||
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
|
||||
|
||||
row_color = None
|
||||
if is_present and has_reason:
|
||||
row_color = '#E2EFDA'
|
||||
elif not is_present and has_reason:
|
||||
row_color = '#FFF2CC'
|
||||
elif not is_present and not has_reason and not has_first_act:
|
||||
row_color = '#FCE4D6'
|
||||
|
||||
val_h_str = str(absence_reason) if has_reason else ""
|
||||
lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1
|
||||
ws.set_row(row_num, max(lines_count * 18, 20))
|
||||
|
||||
values = [
|
||||
(idx + 1, 'center', False),
|
||||
(row.get('Сотрудник', ''), 'left', False),
|
||||
(dept_scud_val, 'center', False),
|
||||
(in_val, 'center', False),
|
||||
(first_act_val, 'center', False),
|
||||
(out_val, 'center', False),
|
||||
(in_building_str, 'center', False),
|
||||
(absence_reason if has_reason else '', 'left', True),
|
||||
(8, 'center', False),
|
||||
(deviation_val, 'center', False)
|
||||
]
|
||||
|
||||
for col_idx, (val, align_type, is_wrap) in enumerate(values):
|
||||
fmt = make_fmt(bg_color=row_color, align=align_type, wrap=is_wrap)
|
||||
ws.write(row_num, col_idx, val, fmt)
|
||||
|
||||
col_widths = {
|
||||
0: 4,
|
||||
1: 33,
|
||||
2: 13,
|
||||
3: 11,
|
||||
4: 11,
|
||||
5: 11,
|
||||
6: 12,
|
||||
7: 24,
|
||||
8: 6,
|
||||
9: 11
|
||||
}
|
||||
|
||||
for col_idx, width in col_widths.items():
|
||||
ws.set_column(col_idx, col_idx, width)
|
||||
|
||||
safe_close_workbook(wb, output_path, target_dir, filename)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. СЫРОЙ СКУД
|
||||
# =============================================================================
|
||||
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
|
||||
output_path = os.path.join(REPORTS_DIR, filename)
|
||||
target_dir = os.path.dirname(output_path)
|
||||
wb = xlsxwriter.Workbook(output_path)
|
||||
ws = wb.add_worksheet("Сырые_данные")
|
||||
|
||||
fmt_hdr = wb.add_format({
|
||||
'font_name': 'Calibri',
|
||||
'font_size': 11,
|
||||
'bold': True,
|
||||
'bg_color': '#D9E1F2',
|
||||
'border': 1,
|
||||
'border_color': '#D3D3D3',
|
||||
'align': 'center',
|
||||
'valign': 'vcenter'
|
||||
})
|
||||
fmt_cell = wb.add_format({
|
||||
'font_name': 'Calibri',
|
||||
'font_size': 11,
|
||||
'border': 1,
|
||||
'border_color': '#D3D3D3',
|
||||
'valign': 'vcenter',
|
||||
'align': 'left'
|
||||
})
|
||||
|
||||
headers = list(df_scud.columns)
|
||||
ws.set_row(3, 28)
|
||||
for col_idx, header in enumerate(headers):
|
||||
ws.write(0, col_idx, str(header), fmt_hdr)
|
||||
|
||||
col_widths = [len(str(h)) for h in headers]
|
||||
|
||||
for row_idx, row_values in enumerate(df_scud.values, start=1):
|
||||
ws.set_row(row_idx, 19)
|
||||
for col_idx, val in enumerate(row_values):
|
||||
if pd.isna(val) or val is None:
|
||||
val_str = ""
|
||||
elif isinstance(val, bool):
|
||||
val_str = "Да" if val else "Нет"
|
||||
else:
|
||||
val_str = str(val)
|
||||
|
||||
ws.write(row_idx, col_idx, val_str, fmt_cell)
|
||||
if len(val_str) > col_widths[col_idx]:
|
||||
col_widths[col_idx] = len(val_str)
|
||||
|
||||
for col_idx, width in enumerate(col_widths):
|
||||
ws.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
|
||||
|
||||
safe_close_workbook(wb, output_path, target_dir, filename)
|
||||
from services.reports.styles import (
|
||||
format_date_ru,
|
||||
get_dated_reports_dir,
|
||||
safe_close_workbook,
|
||||
create_xlsx_format
|
||||
)
|
||||
from services.reports.calculators import calculate_deviation
|
||||
|
||||
# Реэкспорт функций генерации
|
||||
from services.reports.svodka_builder import generate_summary_excel
|
||||
from services.reports.otchet_builder import generate_detailed_excel
|
||||
from services.reports.raw_scud_builder import export_raw_scud
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/reports/calculators.py
|
||||
ROLE: Расчет баланса рабочего времени, обеденного перерыва и отклонений от нормы.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
|
||||
"""
|
||||
Расчет отклонения от нормы.
|
||||
Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение.
|
||||
"""
|
||||
reason_clean = str(reason).strip().lower() if pd.notna(reason) else ""
|
||||
is_remote = "удален" in reason_clean or "дистанцион" in reason_clean
|
||||
|
||||
has_building_time = isinstance(time_in_building_str, str) and time_in_building_str not in ['00:00', '0', '', 'None', 'nan', 'NaN']
|
||||
|
||||
# Если есть уважительная причина (больничный, отпуск, командировка и т.д.) не удаленка
|
||||
if reason_clean != "" and not is_remote:
|
||||
return "0:00"
|
||||
|
||||
# Если удаленщик работал исключительно из дома (00:00 в здании)
|
||||
if is_remote and not has_building_time:
|
||||
return "0:00"
|
||||
|
||||
# Если сотрудника не было в здании и нет уважительной причины
|
||||
if not has_building_time:
|
||||
return f"-{norm_hours}:00"
|
||||
|
||||
try:
|
||||
parts = time_in_building_str.strip().split(':')
|
||||
hh = int(parts[0])
|
||||
mm = int(parts[1]) if len(parts) > 1 else 0
|
||||
total_in_building_minutes = hh * 60 + mm
|
||||
|
||||
if total_in_building_minutes == 0:
|
||||
return "0:00" if is_remote else f"-{norm_hours}:00"
|
||||
|
||||
work_minutes = max(0, total_in_building_minutes - lunch_minutes)
|
||||
norm_minutes = norm_hours * 60
|
||||
diff = work_minutes - norm_minutes
|
||||
|
||||
if diff == 0:
|
||||
return "0:00"
|
||||
|
||||
sign = "-" if diff < 0 else ""
|
||||
abs_diff = abs(diff)
|
||||
res_hh = abs_diff // 60
|
||||
res_mm = abs_diff % 60
|
||||
|
||||
return f"{sign}{res_hh}:{res_mm:02d}"
|
||||
except Exception:
|
||||
return "0:00" if is_remote else f"-{norm_hours}:00"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/reports/otchet_builder.py
|
||||
ROLE: Генератор книги Детального суточного отчета со сверкой 1С:ЗУП.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import pandas as pd
|
||||
import xlsxwriter
|
||||
from services.reports.styles import get_dated_reports_dir, format_date_ru, safe_close_workbook, create_xlsx_format
|
||||
from services.reports.calculators import calculate_deviation
|
||||
|
||||
|
||||
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
|
||||
date_clean = str(date_str).replace('_', '.')
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_clean)} отчет.xlsx"
|
||||
|
||||
df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() if merged_df is not None and not merged_df.empty else pd.DataFrame()
|
||||
|
||||
target_dir = get_dated_reports_dir(date_clean)
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
|
||||
wb = xlsxwriter.Workbook(output_path)
|
||||
ws = wb.add_worksheet("Детальный_отчет")
|
||||
|
||||
fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True})
|
||||
ws.write(1, 1, "Дата:", fmt_date_lbl)
|
||||
ws.write(1, 3, date_clean, fmt_date_lbl)
|
||||
|
||||
headers = [
|
||||
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
|
||||
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
|
||||
]
|
||||
fmt_hdr = create_xlsx_format(wb, font_name='Arial', font_size=10, bg_color='#D9E1F2', bold=True, align="center", wrap=True)
|
||||
ws.set_row(3, 26)
|
||||
for col_idx, h_text in enumerate(headers):
|
||||
ws.write(3, col_idx, h_text, fmt_hdr)
|
||||
|
||||
start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
|
||||
end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
|
||||
hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании'
|
||||
chars_per_line_h = 24
|
||||
|
||||
for idx, row in df_export.reset_index(drop=True).iterrows():
|
||||
row_num = 4 + idx
|
||||
is_present = row.get('Пришел', False)
|
||||
absence_reason = row.get('Вид_отсутствия', '')
|
||||
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
|
||||
|
||||
in_val = str(row.get(start_col, 'Нет входа')).strip()
|
||||
out_val = str(row.get(end_col, 'Нет выхода')).strip()
|
||||
in_building_str = str(row.get(hours_col, '00:00'))
|
||||
first_act_val = str(row.get('Первая_активность', '—')).strip()
|
||||
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
|
||||
|
||||
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
|
||||
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
|
||||
|
||||
row_color = None
|
||||
if is_present and has_reason:
|
||||
row_color = '#E2EFDA'
|
||||
elif not is_present and has_reason:
|
||||
row_color = '#FFF2CC'
|
||||
elif not is_present and not has_reason and not has_first_act:
|
||||
row_color = '#FCE4D6'
|
||||
|
||||
val_h_str = str(absence_reason) if has_reason else ""
|
||||
lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1
|
||||
ws.set_row(row_num, max(lines_count * 18, 20))
|
||||
|
||||
values = [
|
||||
(idx + 1, 'center', False),
|
||||
(row.get('Сотрудник', ''), 'left', False),
|
||||
(dept_scud_val, 'center', False),
|
||||
(in_val, 'center', False),
|
||||
(first_act_val, 'center', False),
|
||||
(out_val, 'center', False),
|
||||
(in_building_str, 'center', False),
|
||||
(absence_reason if has_reason else '', 'left', True),
|
||||
(8, 'center', False),
|
||||
(deviation_val, 'center', False)
|
||||
]
|
||||
|
||||
for col_idx, (val, align_type, is_wrap) in enumerate(values):
|
||||
fmt = create_xlsx_format(wb, font_name='Arial', font_size=10, bg_color=row_color, align=align_type, wrap=is_wrap)
|
||||
ws.write(row_num, col_idx, val, fmt)
|
||||
|
||||
col_widths = {0: 4, 1: 33, 2: 13, 3: 11, 4: 11, 5: 11, 6: 12, 7: 24, 8: 6, 9: 11}
|
||||
for col_idx, width in col_widths.items():
|
||||
ws.set_column(col_idx, col_idx, width)
|
||||
|
||||
safe_close_workbook(wb, output_path, target_dir, filename)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/reports/raw_scud_builder.py
|
||||
ROLE: Генерация Excel-файла сырых данных СКУД.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import xlsxwriter
|
||||
from config import REPORTS_DIR
|
||||
from services.reports.styles import safe_close_workbook, create_xlsx_format
|
||||
|
||||
|
||||
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
|
||||
output_path = os.path.join(REPORTS_DIR, filename)
|
||||
target_dir = os.path.dirname(output_path)
|
||||
wb = xlsxwriter.Workbook(output_path)
|
||||
ws = wb.add_worksheet("Сырые_данные")
|
||||
|
||||
fmt_hdr = create_xlsx_format(wb, bold=True, bg_color='#D9E1F2', align='center')
|
||||
fmt_cell = create_xlsx_format(wb, align='left')
|
||||
|
||||
headers = list(df_scud.columns)
|
||||
ws.set_row(0, 28)
|
||||
for col_idx, header in enumerate(headers):
|
||||
ws.write(0, col_idx, str(header), fmt_hdr)
|
||||
|
||||
col_widths = [len(str(h)) for h in headers]
|
||||
|
||||
for row_idx, row_values in enumerate(df_scud.values, start=1):
|
||||
ws.set_row(row_idx, 19)
|
||||
for col_idx, val in enumerate(row_values):
|
||||
val_str = "" if (pd.isna(val) or val is None) else ("Да" if isinstance(val, bool) and val else ("Нет" if isinstance(val, bool) else str(val)))
|
||||
ws.write(row_idx, col_idx, val_str, fmt_cell)
|
||||
if len(val_str) > col_widths[col_idx]:
|
||||
col_widths[col_idx] = len(val_str)
|
||||
|
||||
for col_idx, width in enumerate(col_widths):
|
||||
ws.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
|
||||
|
||||
safe_close_workbook(wb, output_path, target_dir, filename)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/reports/styles.py
|
||||
ROLE: Стили, палитры цветов, форматирование дат и защита от блокировок Excel.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from xlsxwriter.exceptions import FileCreateError
|
||||
from config import REPORTS_DIR
|
||||
|
||||
MONTHS_RU_GENITIVE = {
|
||||
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
|
||||
5: "мая", 6: "июня", 7: "июля", 8: "августа",
|
||||
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
|
||||
}
|
||||
|
||||
MONTHS_RU_NOMINATIVE = {
|
||||
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
|
||||
5: "май", 6: "июнь", 7: "июль", 8: "август",
|
||||
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь"
|
||||
}
|
||||
|
||||
|
||||
def format_date_ru(date_str):
|
||||
date_clean = str(date_str).replace('_', '.')
|
||||
try:
|
||||
dt = datetime.strptime(date_clean, "%d.%m.%Y")
|
||||
return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}"
|
||||
except Exception:
|
||||
return date_str
|
||||
|
||||
|
||||
def get_dated_reports_dir(date_str):
|
||||
date_clean = str(date_str).replace('_', '.')
|
||||
try:
|
||||
dt = datetime.strptime(date_clean, "%d.%m.%Y")
|
||||
year_str = str(dt.year)
|
||||
month_name = MONTHS_RU_NOMINATIVE[dt.month]
|
||||
except Exception:
|
||||
now = datetime.now()
|
||||
year_str = str(now.year)
|
||||
month_name = MONTHS_RU_NOMINATIVE[now.month]
|
||||
|
||||
target_dir = os.path.join(REPORTS_DIR, year_str, month_name)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
return target_dir
|
||||
|
||||
|
||||
def safe_close_workbook(wb, output_path, target_dir, filename):
|
||||
try:
|
||||
wb.close()
|
||||
return output_path
|
||||
except (FileCreateError, OSError, PermissionError):
|
||||
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
|
||||
alt_path = os.path.join(target_dir, alt_filename)
|
||||
try:
|
||||
wb.filename = alt_path
|
||||
wb._store_workbook()
|
||||
return alt_path
|
||||
except Exception:
|
||||
return output_path
|
||||
|
||||
|
||||
def create_xlsx_format(workbook, font_name="Calibri", font_size=11, bg_color=None, bold=False, align="left", wrap=False):
|
||||
fmt_dict = {
|
||||
'font_name': font_name,
|
||||
'font_size': font_size,
|
||||
'bold': bold,
|
||||
'align': align,
|
||||
'valign': 'vcenter',
|
||||
'border': 1,
|
||||
'border_color': '#D3D3D3',
|
||||
'text_wrap': wrap
|
||||
}
|
||||
if bg_color:
|
||||
fmt_dict['bg_color'] = bg_color
|
||||
return workbook.add_format(fmt_dict)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/reports/svodka_builder.py
|
||||
ROLE: Генератор книги Ежедневной сводки (иерархические группировки XlsxWriter).
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import pandas as pd
|
||||
import xlsxwriter
|
||||
from services.reports.styles import get_dated_reports_dir, format_date_ru, safe_close_workbook, create_xlsx_format
|
||||
|
||||
|
||||
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
|
||||
date_clean = str(date_str).replace('_', '.')
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_clean)} сводка.xlsx"
|
||||
|
||||
target_dir = get_dated_reports_dir(date_clean)
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
|
||||
wb = xlsxwriter.Workbook(output_path)
|
||||
ws = wb.add_worksheet("Лист_1")
|
||||
ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False)
|
||||
|
||||
fmt_hdr_l = create_xlsx_format(wb, bg_color='#D9E1F2', bold=True, align="left")
|
||||
fmt_hdr_r = create_xlsx_format(wb, bg_color='#D9E1F2', bold=True, align="right")
|
||||
fmt_tot_l = create_xlsx_format(wb, bg_color='#F2F2F2', bold=True, align="left")
|
||||
fmt_tot_r = create_xlsx_format(wb, bg_color='#F2F2F2', bold=True, align="right")
|
||||
fmt_empty = create_xlsx_format(wb)
|
||||
|
||||
ws.set_row(0, 20)
|
||||
ws.write(0, 0, "Сводка на", fmt_hdr_l)
|
||||
ws.write(0, 1, date_clean, fmt_hdr_r)
|
||||
|
||||
ws.set_row(1, 20)
|
||||
ws.write(1, 0, "", fmt_empty)
|
||||
ws.write(1, 1, "", fmt_empty)
|
||||
|
||||
ws.set_row(2, 20)
|
||||
ws.write(2, 0, "По списку", fmt_tot_l)
|
||||
ws.write(2, 1, len(merged_df), fmt_tot_r)
|
||||
|
||||
current_row = 3
|
||||
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
|
||||
is_exc = merged_df.get('is_excluded', False) == True
|
||||
|
||||
# 1. Неизвестно
|
||||
unexplained = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
||||
(~is_no_pass) & (~is_exc)
|
||||
]
|
||||
fmt_unexp_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left")
|
||||
fmt_unexp_hr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="right")
|
||||
fmt_unexp_rl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left")
|
||||
fmt_unexp_rr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "неизвестно", fmt_unexp_hl)
|
||||
ws.write(current_row, 1, len(unexplained), fmt_unexp_hr)
|
||||
current_row += 1
|
||||
|
||||
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
|
||||
ws.write(current_row, 0, fio, fmt_unexp_rl)
|
||||
ws.write(current_row, 1, "", fmt_unexp_rr)
|
||||
current_row += 1
|
||||
|
||||
# 2. Нет пропуска
|
||||
no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
|
||||
fmt_np_hl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="left")
|
||||
fmt_np_hr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="right")
|
||||
fmt_np_rl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="left")
|
||||
fmt_np_rr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "Нет пропуска", fmt_np_hl)
|
||||
ws.write(current_row, 1, len(no_pass_df), fmt_np_hr)
|
||||
current_row += 1
|
||||
|
||||
if not no_pass_df.empty:
|
||||
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
|
||||
ws.write(current_row, 0, fio, fmt_np_rl)
|
||||
ws.write(current_row, 1, "", fmt_np_rr)
|
||||
current_row += 1
|
||||
|
||||
# 3. Официальные отсутствия
|
||||
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
|
||||
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
|
||||
|
||||
absent_only = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
|
||||
(~is_remote_reason)
|
||||
]
|
||||
absent_groups = absent_only.groupby('Вид_отсутствия')
|
||||
pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF']
|
||||
|
||||
for idx_cat, (cat_name, group) in enumerate(absent_groups):
|
||||
hex_c = pastels[idx_cat % len(pastels)]
|
||||
fmt_cat_hl = create_xlsx_format(wb, bg_color=hex_c, bold=True, align="left")
|
||||
fmt_cat_hr = create_xlsx_format(wb, bg_color=hex_c, bold=True, align="right")
|
||||
fmt_cat_rl = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="left")
|
||||
fmt_cat_rr = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, cat_name, fmt_cat_hl)
|
||||
ws.write(current_row, 1, len(group), fmt_cat_hr)
|
||||
current_row += 1
|
||||
|
||||
is_other_category = (str(cat_name).strip().lower() == "иное")
|
||||
for _, row in group.sort_values(by='Сотрудник').iterrows():
|
||||
fio = row.get('Сотрудник', '')
|
||||
detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else ""
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
|
||||
ws.write(current_row, 0, fio, fmt_cat_rl)
|
||||
ws.write(current_row, 1, detail_val, fmt_cat_rr)
|
||||
current_row += 1
|
||||
|
||||
# 4. Итого на работе
|
||||
exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))]
|
||||
present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
|
||||
total_present_count = len(present_scud) + len(exc_without_doc)
|
||||
|
||||
fmt_pres_hl = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="left")
|
||||
fmt_pres_hr = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "Итого на работе", fmt_pres_hl)
|
||||
ws.write(current_row, 1, total_present_count, fmt_pres_hr)
|
||||
current_row += 1
|
||||
|
||||
# 5. Удаленная работа
|
||||
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
|
||||
fmt_rem_hl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="left")
|
||||
fmt_rem_hr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="right")
|
||||
fmt_rem_rl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="left")
|
||||
fmt_rem_rr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="right")
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl)
|
||||
ws.write(current_row, 1, len(remote_home), fmt_rem_hr)
|
||||
current_row += 1
|
||||
|
||||
if not remote_home.empty:
|
||||
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
|
||||
ws.write(current_row, 0, fio, fmt_rem_rl)
|
||||
ws.write(current_row, 1, "", fmt_rem_rr)
|
||||
current_row += 1
|
||||
|
||||
# 6. Аномалии СКУД и 1С
|
||||
anomalies = merged_df[
|
||||
(~is_exc) & (
|
||||
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
|
||||
(~is_remote_reason) &
|
||||
(~reason_clean.str.contains('командировк', na=False))) |
|
||||
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
|
||||
)
|
||||
]
|
||||
fmt_anom_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left")
|
||||
fmt_anom_hr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="right")
|
||||
fmt_anom_rl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left")
|
||||
fmt_anom_rr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left", wrap=True)
|
||||
|
||||
ws.set_row(current_row, 20)
|
||||
ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl)
|
||||
ws.write(current_row, 1, len(anomalies), fmt_anom_hr)
|
||||
current_row += 1
|
||||
|
||||
chars_per_line_b = 30
|
||||
if not anomalies.empty:
|
||||
for _, row in anomalies.iterrows():
|
||||
fio = row.get('Сотрудник', '')
|
||||
anom_flag = row.get('anomaly_flag', 'NONE')
|
||||
reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {row.get('Первая_активность', '—')})" if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY' else f"В 1С: {row.get('Вид_отсутствия', '')}"
|
||||
|
||||
lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
|
||||
ws.set_row(current_row, max(lines_count * 18, 20), None, {'level': 1, 'hidden': True, 'collapsed': True})
|
||||
ws.write(current_row, 0, fio, fmt_anom_rl)
|
||||
ws.write(current_row, 1, reason_text, fmt_anom_rr)
|
||||
current_row += 1
|
||||
|
||||
ws.set_column(0, 0, 45)
|
||||
ws.set_column(1, 1, 38)
|
||||
safe_close_workbook(wb, output_path, target_dir, filename)
|
||||
@@ -1,40 +1,46 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/sql_queries.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / scud_etl
|
||||
ROLE: Хранилище сырых SQL-шаблонов для выгрузки из MS SQL Server (СКУД Орион Pro).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]: T-SQL запрос с расчетом первой активности и длительности.
|
||||
ROLE: Изолированные SQL-шаблоны для MS SQL Server (СКУД Орион Pro).
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]
|
||||
SCUD_EXPORT_QUERY_TEMPLATE = r"""
|
||||
# 1. Основной срез СКУД с учетом двухконтурного правого турникета
|
||||
SQL_QUERY_TEMPLATE = r"""
|
||||
DECLARE @InputDate DATE = '{target_date}';
|
||||
DECLARE @TargetDate DATE = @InputDate;
|
||||
|
||||
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
|
||||
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
|
||||
DECLARE @EndDate DATETIME = {end_datetime_sql};
|
||||
|
||||
WITH DailyLogs AS (
|
||||
WITH PercoPassages AS (
|
||||
-- Физические факты прохода (Event = 32)
|
||||
SELECT
|
||||
log.HozOrgan AS EmployeeID,
|
||||
log.TimeVal,
|
||||
log.Event,
|
||||
log.Mode,
|
||||
CASE
|
||||
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
|
||||
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
|
||||
WHEN log.Mode = 1 THEN 'IN'
|
||||
WHEN log.Mode = 2 THEN 'OUT'
|
||||
ELSE 'OTHER'
|
||||
END AS Direction,
|
||||
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
|
||||
END AS Direction
|
||||
FROM pLogData log WITH (NOLOCK)
|
||||
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
|
||||
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
|
||||
AND log.Event = 32
|
||||
AND log.Mode IN (1, 2)
|
||||
AND (
|
||||
-- Контур 1: Левый турникет открыт для всех
|
||||
log.DoorIndex = 1
|
||||
OR
|
||||
-- Контур 2: Правый турникет разрешен только для реестра двора
|
||||
(
|
||||
log.DoorIndex = 2
|
||||
AND ({turnstile_filter_sql})
|
||||
)
|
||||
)
|
||||
),
|
||||
Passages AS (
|
||||
SELECT
|
||||
@@ -42,10 +48,21 @@ Passages AS (
|
||||
MIN(TimeVal) AS FirstRawEvent,
|
||||
MAX(TimeVal) AS LastRawEvent,
|
||||
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
|
||||
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
|
||||
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
|
||||
FROM DailyLogs
|
||||
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS FinalOut
|
||||
FROM PercoPassages
|
||||
GROUP BY EmployeeID
|
||||
),
|
||||
EvaluatedPassages AS (
|
||||
SELECT
|
||||
p.*,
|
||||
CASE
|
||||
WHEN p.FinalOut IS NOT NULL
|
||||
AND p.FirstIn IS NOT NULL
|
||||
AND p.FinalOut > DATEADD(MINUTE, 5, p.FirstIn)
|
||||
THEN p.FinalOut
|
||||
ELSE NULL
|
||||
END AS FilteredLastOut
|
||||
FROM Passages p
|
||||
)
|
||||
SELECT
|
||||
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
|
||||
@@ -67,12 +84,8 @@ SELECT
|
||||
ELSE N'—'
|
||||
END AS [Первая_активность],
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
|
||||
THEN N'Нет выхода'
|
||||
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
|
||||
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
|
||||
WHEN pass.FilteredLastOut IS NOT NULL
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.FilteredLastOut, 108) AS NVARCHAR(20))
|
||||
ELSE N'Нет выхода'
|
||||
END AS [Конец_дня],
|
||||
CASE
|
||||
@@ -80,14 +93,14 @@ SELECT
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
|
||||
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
|
||||
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
|
||||
ELSE @EndDate
|
||||
END) / 60 AS VARCHAR), 2) + ':' +
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
|
||||
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
|
||||
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
|
||||
ELSE @EndDate
|
||||
END) % 60 AS VARCHAR), 2)
|
||||
ELSE N'00:00'
|
||||
END AS [Находился_в_здании],
|
||||
@@ -98,7 +111,7 @@ SELECT
|
||||
FROM pList p WITH (NOLOCK)
|
||||
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
|
||||
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
|
||||
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID
|
||||
LEFT JOIN EvaluatedPassages pass ON p.ID = pass.EmployeeID
|
||||
WHERE
|
||||
ISNULL(p.StatusRecord, 0) = 0
|
||||
AND p.DateTimeInArchive IS NULL
|
||||
@@ -114,3 +127,41 @@ WHERE
|
||||
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
|
||||
ORDER BY p.Name ASC;
|
||||
"""
|
||||
|
||||
# 2. Сырые события физических проходов турникетов
|
||||
SQL_RAW_EVENTS_QUERY = r"""
|
||||
DECLARE @InputDate DATE = '{target_date}';
|
||||
DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME);
|
||||
DECLARE @EndDate DATETIME = {end_datetime_sql};
|
||||
|
||||
SELECT
|
||||
log.TimeVal,
|
||||
log.HozOrgan,
|
||||
LTRIM(RTRIM(
|
||||
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
|
||||
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
|
||||
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
|
||||
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
|
||||
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
|
||||
)) AS [Сотрудник],
|
||||
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
|
||||
log.Event,
|
||||
log.Mode,
|
||||
log.DoorIndex,
|
||||
CASE
|
||||
WHEN log.Mode = 2 THEN 'OUT'
|
||||
WHEN log.Mode = 1 THEN 'IN'
|
||||
WHEN log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
|
||||
WHEN log.Event IN (1, 21, 26, 54, 64) THEN 'IN'
|
||||
ELSE 'OTHER'
|
||||
END AS Direction
|
||||
FROM pLogData log WITH (NOLOCK)
|
||||
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
|
||||
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND log.Event IN (28, 32)
|
||||
AND ISNULL(p.StatusRecord, 0) = 0
|
||||
ORDER BY log.TimeVal ASC;
|
||||
"""
|
||||
@@ -23,6 +23,7 @@ import pandas as pd
|
||||
import pyodbc
|
||||
import xlsxwriter
|
||||
|
||||
from services.scud_etl.sql_queries import SQL_QUERY_TEMPLATE, SQL_RAW_EVENTS_QUERY
|
||||
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
|
||||
from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot
|
||||
from core.repositories.scud_repo import save_raw_events_to_db
|
||||
|
||||
Reference in New Issue
Block a user