28 Commits
Author SHA1 Message Date
manoraga 11691701e1 docs(office): add section 19 to roadmap and changelog for two-phase handwritten ocr pipeline 2026-09-25 16:35:27 +03:00
manoraga 90656944c5 feat(reports): stabilize on-demand generation, live presence and 1C fallback 2026-09-25 13:00:11 +03:00
manoraga 2f8cc1bffd feat(reports): add simplified report builder, fix nan/inf crash, optimize snapshot tool 2026-09-24 09:51:19 +03:00
manoraga 30651672ab feat(reports): add simplified report builder, fix nan/inf crash and separate unhired/no-pass staff 2026-09-24 09:25:57 +03:00
manoraga 9c89b6592e fix(db_prompts): передача row_factory=True для корректного преобразования строк SQLite в словари 2026-09-11 16:14:44 +03:00
manoraga 565cf5af37 refactor(web): модуляризация модальных окон index.html и синхронизация диагностических слепков 2026-09-11 16:02:49 +03:00
manoraga 86f223a21e fix(db_chat): включение row_factory=True для доступа по именам колонок 2026-09-11 15:55:47 +03:00
manoraga b1796de852 refactor(scud): изоляция тяжелых SQL-шаблонов Орион в sql_queries и разгрузка scud_export 2026-09-11 15:42:14 +03:00
manoraga aa1141fb88 refactor(reports): модульное разделение excel_exporter на styles, calculators и билдеры отчетов 2026-09-11 15:29:48 +03:00
manoraga 66a17bde45 refactor(core): декомпозиция db_cli, сервисный дамп БД и зачистка контекста 2026-09-11 15:25:44 +03:00
manoraga baa5073b88 refactor(core): удаление дубликата ai_engine, единый контур SQLite и фикс флага крона 2026-09-11 15:17:03 +03:00
manoraga 74332aa38a feat(turnstile): двухконтурный учет СКУД, реестры исключений с автокомплитом 1С и калибровка таймзон 2026-09-10 15:36:32 +03:00
manoraga 6c9b131cf2 feat(scud): добавление таблицы scud_events_raw и сохранение детальной ленты проходов 2026-09-07 18:25:06 +03:00
manoraga 17c960fa34 fix(scud): исправление маппинга Mode (1=IN, 2=OUT) и привязка FilteredLastOut к финальному событию дня 2026-09-07 17:41:59 +03:00
manoraga d391a08224 feat(web): стабилизация UI, Gemini-скроллинг, роутеры контекста/снапшотов и актуализация роадмапа 2026-09-07 17:29:21 +03:00
manoraga 1bb95cd8e1 chore: ignore sqlite temporary wal and shm files 2026-08-28 00:12:39 +03:00
manoraga 27e8055a3d feat: implement svodka/otchet generators, Y-23:59:59 and snap-to-grid time finder 2026-08-28 00:09:57 +03:00
manoraga fa386b3b79 docs: update CHANGELOG and ROADMAP with section 10 (svodka/otchet split, Y-23:59:59) 2026-08-27 22:43:45 +03:00
manoraga a9680db0aa feat(etl): stable pipeline, exception registry in SQLite, multi-pass aggregation and db_cli 2026-08-27 19:26:28 +03:00
manoraga 66087d5806 merge: внедрение нативного Function Calling, двухфазного жизненного цикла и Topic Drift Guard 2026-08-18 13:49:48 +03:00
manoraga 4b1b8a3586 feat(llm): двухфазный жизненный цикл сессий, двусторонний Diff, Topic Drift Guard и актуализация документации 2026-08-18 13:47:26 +03:00
manoraga 828ce67817 feat(llm): переход на реляционные узлы промпта, db_cli context и очистка от регулярок 2026-08-17 11:55:07 +03:00
manoraga 4e3c99b728 refactor(web_api): fix import paths, modularize routers and decompose agent pipeline 2026-08-15 15:41:37 +03:00
manoraga c0a5145604 15.08.2026 13:20 добился очистки эфемерных сообщений с помощью анализа ведения диалога с помощью ИИ и по нажатию кнопки "Завершить" 2026-08-15 13:23:38 +03:00
manoraga 7ee7520c98 feat(llm): implement full ephemeral context purging and topic drift state guard 2026-08-15 13:22:03 +03:00
manoraga 0e33e61ab2 chore(checkpoint): save working state before clean state context refactoring 2026-08-15 12:45:02 +03:00
manoraga 86047155a8 docs: add roadmap; feat: complete ephemeral context baseline 2026-08-14 19:33:22 +03:00
manoraga bf6d09b106 docs: введен механизм очистки мусорного контекста. Готовность к внедрению интеллектуального механизма очистки контекста. 2026-08-14 19:17:45 +03:00
122 changed files with 27448 additions and 8861 deletions
+7
View File
@@ -27,3 +27,10 @@ data/1c/*
!data/scud/.gitkeep
!data/1c/.gitkeep
!data/static_reason_workers.csv
*.db-shm
*.db-wal
data/scud_orion_ai.db
data/uploads/
*snapshot.md
*.patch
+28 -18
View File
@@ -8,11 +8,11 @@ DATA_DIR = os.path.join(BASE_DIR, "data")
SCUD_DIR = os.path.join(DATA_DIR, "scud")
ZUP_1C_DIR = os.path.join(DATA_DIR, "1c")
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
REPORTS_DIR = os.path.join(OUTPUT_DIR, "reports")
# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат)
SHARE_1C_DIR = "/mnt/scud_share"
for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]:
for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR, REPORTS_DIR]:
os.makedirs(folder, exist_ok=True)
NOW = datetime.now()
@@ -23,32 +23,30 @@ if NOW.weekday() == 0:
else:
DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
# --- Настройки LLM / Ollama ---
OLLAMA_URL = "http://192.168.11.3:11434/api/generate"
OLLAMA_URL = "http://10.121.17.227:11434/api/generate"
OLLAMA_MODEL = "qwen2.5:14b"
MODEL_NAME = OLLAMA_MODEL
KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json")
EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json")
# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) ---
ZUP_SQL_CONFIG = {
"driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}"
"driver": "{ODBC Driver 18 for SQL Server}",
"server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"),
"database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01
"user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server
"password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server
"trust_server_certificate": "yes", # Доверять сертификату сервера
"encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование
"database": os.getenv("ZUP_SQL_DB", "ZUP30"),
"user": os.getenv("ZUP_SQL_USER", "scud_reader"),
"password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"),
"trust_server_certificate": "yes",
"encrypt": "no"
}
def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]):
"""
Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026).
"""
date_dots = date_str
date_underscores = date_str.replace('.', '_')
def find_dated_file(prefix, date_str, search_dirs=None):
if search_dirs is None:
search_dirs = [ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]
date_dots = str(date_str).replace('_', '.')
date_underscores = date_dots.replace('.', '_')
for d in search_dirs:
if not os.path.exists(d):
@@ -75,6 +73,18 @@ def clean_scud_fio_light(fio_str):
def load_exceptions():
"""
Приоритетно читает исключения и белый список из SQLite таблицы exceptions_registry.
При отсутствии таблицы или пустой базе выполняет fallback на exceptions.json.
"""
try:
from services.exceptions_repo import get_all_exceptions_from_db
db_exc = get_all_exceptions_from_db()
if any(db_exc.values()):
return db_exc
except Exception:
pass
import json
if os.path.exists(EXCEPTIONS_PATH):
try:
@@ -82,4 +92,4 @@ def load_exceptions():
return json.load(f)
except Exception:
pass
return {"fio": [], "departments": [], "positions": [], "position_keywords": []}
return {"fio": [], "departments": [], "positions": [], "position_keywords": [], "include_fio": []}
+27
View File
@@ -0,0 +1,27 @@
"""
===============================================================================
FILE: core/connection.py
PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: Единый менеджер подключений к базе данных SQLite (WAL mode, timeouts).
===============================================================================
"""
import os
import sqlite3
from config import DATA_DIR
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def get_connection(row_factory: bool = False) -> sqlite3.Connection:
"""
Создает оптимизированное подключение к SQLite.
row_factory=True возвращает sqlite3.Row для доступа к полям по имени.
"""
conn = sqlite3.connect(DB_PATH, timeout=30.0)
if row_factory:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA synchronous = NORMAL;")
return conn
+47 -470
View File
@@ -1,480 +1,57 @@
"""
===============================================================================
FILE: core/database.py
ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов.
===============================================================================
"""
import os
import sqlite3
import json
import pandas as pd
from datetime import datetime
from config import DATA_DIR
from config import OUTPUT_DIR
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
from core.connection import get_connection, DB_PATH
from core.schema import init_all_tables
from core.repositories.scud_repo import (
has_scud_logs_for_date,
has_yesterday_final_snapshot,
get_or_create_snapshot_id,
save_scud_to_db,
get_latest_snapshot_time,
load_scud_from_db_by_snapshot,
get_available_snapshots,
delete_snapshot_by_id,
delete_snapshots_by_date
)
def get_connection():
"""Создает подключение к базе данных SQLite с оптимизированными настройками."""
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;")
return conn
from core.repositories.zup_repo import (
save_staff_to_db,
load_staff_from_db,
save_absences_to_db,
load_absences_from_db,
save_anomalies_to_db,
get_all_rules_from_db,
add_rule_to_db,
get_department_synonyms_dict,
add_department_synonym_to_db
)
init_db = init_all_tables
def init_db():
"""Создает структуру таблиц и индексов в базе данных."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
time_in TEXT,
first_activity TEXT DEFAULT '—',
time_out TEXT,
time_in_building TEXT,
is_present INTEGER NOT NULL,
anomaly_flag TEXT DEFAULT 'NONE',
snapshot_time TEXT DEFAULT NULL,
snapshot_id TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("PRAGMA table_info(scud_logs);")
cols = [col[1] for col in cursor.fetchall()]
if 'snapshot_id' not in cols:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;")
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_staff (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
absence_type TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anomaly_date TEXT NOT NULL,
fio TEXT NOT NULL,
anomaly_type TEXT NOT NULL,
details TEXT NOT NULL,
human_status TEXT DEFAULT 'Pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS ai_knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_text TEXT UNIQUE NOT NULL,
added_by TEXT DEFAULT 'Human',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
conn.commit()
sync_knowledge_base_to_db()
def has_scud_logs_for_date(date_str):
"""Проверяет, есть ли в базе данные СКУД за указанную дату."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
return cursor.fetchone() is not None
def has_yesterday_final_snapshot(date_str):
"""Проверяет, зафиксирован ли уже ИТОГОВЫЙ вчерашний снапшот с индексом Y/22:00."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
(date_str,)
)
return cursor.fetchone() is not None
def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False):
"""
Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN).
Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time).
"""
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:
dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError):
dt_snap = datetime.now().date()
date_prefix = dt_snap.strftime("%Y%m%d")
# Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y
if date_str:
try:
dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
if dt_log < dt_snap:
is_yesterday = True
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
except Exception:
pass
prefix = "Y" if is_yesterday else ""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(date_str, snapshot_time)
)
else:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(snapshot_time,)
)
row = cursor.fetchone()
if row and row[0]:
return row[0]
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
ORDER BY snapshot_id DESC LIMIT 1
""", (f"{date_prefix}-%", f"Y{date_prefix}-%"))
last_row = cursor.fetchone()
next_seq = 1
if last_row and last_row[0]:
parts = last_row[0].replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
next_seq = int(parts[1]) + 1
return f"{prefix}{date_prefix}-{next_seq:03d}"
def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False):
"""Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id."""
if df_scud is None or df_scud.empty:
return
if not snapshot_time:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Передаем date_str в генератор snapshot_id
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
data_to_insert = []
for _, r in df_scud.iterrows():
data_to_insert.append((
date_str,
r.get('Сотрудник', r.get('fio_raw', '')),
r.get('fio_clean', ''),
r.get('Подразделение', ''),
r.get('Должность', ''),
r.get('Начало_дня', 'Нет входа'),
r.get('Первая_активность', '—'),
r.get('Конец_дня', 'Нет выхода'),
r.get('Находился_в_здании', '00:00'),
1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'),
snapshot_time,
snapshot_id
))
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
cursor.executemany("""
INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time, snapshot_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def get_latest_snapshot_time(date_str=None):
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
else:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
row = cursor.fetchone()
return row[0] if row else None
def load_scud_from_db_by_snapshot(date_str, snapshot_param=None):
"""
Загружает логи СКУД по ID снапшота.
Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает
финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str.
"""
with get_connection() as conn:
df = pd.DataFrame()
# 1. Если передан конкретный ID снапшота (например, '20260810-001')
if snapshot_param:
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(str(snapshot_param),)
)
# 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день):
if df.empty and date_str:
cursor = conn.cursor()
# ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот)
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
# ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный
if not row:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
if row and row[0]:
target_snap_id = row[0]
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(target_snap_id,)
)
# Приводим названия колонок к стандарту
if not df.empty:
rename_map = {
'department': 'Подразделение',
'position': 'Должность',
'fio': 'Сотрудник',
'time_in': 'Начало_дня',
'first_activity': 'Первая_активность',
'time_out': 'Конец_дня',
'duration': 'Находился_в_здании',
'is_present': 'Пришел'
}
# Переименовываем имеющиеся колонки
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
# Заполняем недостающие колонки значениями по умолчанию, если их не было в БД
required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']
for col in required_cols:
if col not in df.columns:
if col == 'Пришел':
df[col] = False
else:
df[col] = '—'
# Приводим тип флага Пришел к bool
if 'Пришел' in df.columns:
df['Пришел'] = df['Пришел'].astype(bool)
return df
def get_available_snapshots(date_str=None):
"""
Возвращает список снапшотов.
Сортировка по времени snapshot_time DESC.
"""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""", (date_str,))
else:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""")
return cursor.fetchall()
def save_staff_to_db(df_staff, date_str):
if df_staff is None or df_staff.empty:
return
data_to_insert = [
(date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
for _, r in df_staff.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data_to_insert)
conn.commit()
def save_absences_to_db(df_absent, date_str):
if df_absent is None or df_absent.empty:
return
data_to_insert = [
(date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
for _, r in df_absent.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data_to_insert)
conn.commit()
def save_anomalies_to_db(anomalies_list, date_str):
if not anomalies_list:
return
data_to_insert = [
(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', ''))
for a in anomalies_list
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data_to_insert)
conn.commit()
def get_all_rules_from_db():
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT rule_text FROM ai_knowledge_base")
return [r[0] for r in cursor.fetchall()]
def add_rule_to_db(rule_text, added_by="Human"):
if not rule_text or not rule_text.strip():
return
with get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
conn.commit()
except Exception as e:
print(f"[⚠️] Ошибка записи правила в БД: {e}")
def sync_knowledge_base_to_db():
pass
def delete_snapshot_by_id(snapshot_id: str):
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}")
return deleted_count
def delete_snapshots_by_date(date_str: str):
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
with get_connection() as conn:
cursor = conn.cursor()
# Удаляем по log_date или по дате внутри snapshot_id
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}")
return deleted_count
def init_department_synonyms_db():
"""Создает таблицу синонимов отделов в БД SQLite."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS department_synonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_name TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# По умолчанию добавляем ОВК -> Отдел внутреннего контроля
cursor.execute("""
INSERT OR IGNORE INTO department_synonyms (short_name, full_name)
VALUES ('овк', 'отдел внутреннего контроля')
""")
conn.commit()
def get_department_synonyms_dict():
"""Возвращает словарь всех изученных синонимов {short_name: full_name}."""
init_department_synonyms_db()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
return {row[0]: row[1] for row in cursor.fetchall()}
def add_department_synonym_to_db(short_name, full_name):
"""Сохраняет новую пару синонимов отдела в базу SQLite."""
init_department_synonyms_db()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)",
(short_name.strip().lower(), full_name.strip().lower())
)
conn.commit()
print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'")
def load_staff_from_db(date_str):
"""Загружает Штатное расписание 1С из базы SQLite за указанную дату."""
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
def load_absences_from_db(date_str):
"""Загружает документальные отсутствия 1С из базы SQLite за указанную дату."""
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
return out_path
+359
View File
@@ -0,0 +1,359 @@
"""
===============================================================================
FILE: core/repositories/scud_repo.py
ROLE: Репозиторий логов СКУД, сохранение и загрузка снапшотов.
===============================================================================
"""
from datetime import datetime
import pandas as pd
from core.connection import get_connection
def has_scud_logs_for_date(date_str: str) -> bool:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
return cursor.fetchone() is not None
def has_yesterday_final_snapshot(date_str: str) -> bool:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT 1 FROM scud_logs
WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_id LIKE '%_FINAL%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00')
LIMIT 1
""",
(date_str,)
)
return cursor.fetchone() is not None
def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str:
"""
Генерирует понятный и уникальный ID снапшота:
- Дата префикса берется строго из даты самих логов (date_str).
- Для итоговых срезов дня: YYYYYMMDD_FINAL (строго с буквой Y в начале).
- Для дневных срезов на время: YYYYMMDD_HHMM.
"""
if date_str:
try:
dt_log = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").date()
date_prefix = dt_log.strftime("%Y%m%d")
except Exception:
dt_log = datetime.now().date()
date_prefix = dt_log.strftime("%Y%m%d")
else:
try:
dt_snap = datetime.strptime(snapshot_time.split()[0], "%Y-%m-%d").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError, IndexError):
date_prefix = datetime.now().strftime("%Y%m%d")
time_part = "2359"
try:
t_str = snapshot_time.split()[1] if " " in snapshot_time else snapshot_time
t_parts = t_str.split(":")
time_part = f"{t_parts[0]}{t_parts[1]}"
except Exception:
pass
with get_connection() as conn:
cursor = conn.cursor()
# Если срез с точно таким же временем и датой уже существует — возвращаем его ID
if date_str:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(date_str, snapshot_time)
)
else:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(snapshot_time,)
)
row = cursor.fetchone()
if row and row[0]:
return row[0]
# Финальный суточный ID: строго с префиксом Y
if is_yesterday or time_part in ["2359", "2200"]:
base_final_id = f"Y{date_prefix}_FINAL"
cursor.execute("SELECT 1 FROM scud_logs WHERE snapshot_id = ? LIMIT 1", (base_final_id,))
if not cursor.fetchone():
return base_final_id
return base_final_id
# Дневной срез на определенное время
base_id = f"{date_prefix}_{time_part}"
cursor.execute("SELECT 1 FROM scud_logs WHERE snapshot_id = ? LIMIT 1", (base_id,))
if not cursor.fetchone():
return base_id
cursor.execute("""
SELECT DISTINCT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ?
""", (f"{base_id}-%",))
rows = cursor.fetchall()
max_seq = 1
for (s_id,) in rows:
if not s_id:
continue
try:
parts = str(s_id).split('-')
if len(parts) >= 2 and parts[-1].isdigit():
num = int(parts[-1])
if num > max_seq:
max_seq = num
except Exception:
continue
next_seq = max_seq + 1
return f"{base_id}-{next_seq:03d}"
def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None:
if df_scud is None or df_scud.empty:
return
now_local_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if not snapshot_time:
snapshot_time = now_local_str
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
data_to_insert = [
(
date_str,
r.get('Сотрудник', r.get('fio_raw', '')),
r.get('fio_clean', ''),
r.get('Подразделение', ''),
r.get('Должность', ''),
r.get('Начало_дня', 'Нет входа'),
r.get('Первая_активность', '—'),
r.get('Конец_дня', 'Нет выхода'),
r.get('Находился_в_здании', '00:00'),
1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'),
snapshot_time,
snapshot_id,
now_local_str # ⭐️ Передаем локальное время машины напрямую
)
for _, r in df_scud.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
cursor.executemany("""
INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time, snapshot_id, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def get_latest_snapshot_time(date_str: str = None):
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
else:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
row = cursor.fetchone()
return row[0] if row else None
def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> pd.DataFrame:
with get_connection() as conn:
df = pd.DataFrame()
if snapshot_param:
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(str(snapshot_param),)
)
if df.empty and date_str:
cursor = conn.cursor()
cursor.execute(
"""
SELECT snapshot_id FROM scud_logs
WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_id LIKE '%_FINAL%')
ORDER BY snapshot_time DESC, id DESC LIMIT 1
""",
(date_str,)
)
row = cursor.fetchone()
if not row:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
if row and row[0]:
target_id = row[0]
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(target_id,)
)
if not df.empty:
rename_map = {
'department': 'department_scud',
'position': 'Должность',
'fio': 'Сотрудник',
'time_in': 'Начало_дня',
'first_activity': 'Первая_активность',
'time_out': 'Конец_дня',
'time_in_building': 'Находился_в_здании',
'is_present': 'Пришел'
}
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
if 'department_scud' in df.columns and 'Подразделение' not in df.columns:
df['Подразделение'] = df['department_scud']
for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']:
if col not in df.columns:
df[col] = False if col == 'Пришел' else '—'
if 'Пришел' in df.columns:
df['Пришел'] = df['Пришел'].astype(bool)
return df
def get_available_snapshots(date_str: str = None):
with get_connection() as conn:
cursor = conn.cursor()
query = """
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt,
MIN(created_at) as created_at
FROM scud_logs
WHERE snapshot_time IS NOT NULL
"""
params = []
if date_str:
query += " AND log_date = ?"
params.append(date_str)
query += " GROUP BY snapshot_id, log_date, snapshot_time ORDER BY snapshot_time DESC"
cursor.execute(query, params)
return cursor.fetchall()
def delete_snapshot_by_id(snapshot_id: str) -> int:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
cnt = cursor.rowcount
conn.commit()
return cnt
def delete_snapshots_by_date(date_str: str) -> int:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
cnt = cursor.rowcount
conn.commit()
return cnt
def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int:
if df_raw_events is None or df_raw_events.empty:
return 0
records = [
(
date_str,
str(r.get('TimeVal', '')),
int(r.get('HozOrgan', 0)),
str(r.get('Сотрудник', '')),
str(r.get('fio_clean', '')),
str(r.get('Подразделение', '')),
int(r.get('Event', 0)),
int(r.get('Mode', 0)),
int(r.get('DoorIndex')) if pd.notna(r.get('DoorIndex')) else None,
str(r.get('Direction', 'OTHER'))
)
for _, r in df_raw_events.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_events_raw WHERE log_date = ?", (date_str,))
cursor.executemany("""
INSERT INTO scud_events_raw (
log_date, time_val, hoz_organ, fio, fio_clean,
department, event_code, mode, door_index, direction
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", records)
conn.commit()
return len(records)
def get_building_presence(date_str: str, only_inside: bool = True) -> list[dict]:
with get_connection() as conn:
cursor = conn.cursor()
query = """
WITH RankedEvents AS (
SELECT
hoz_organ,
fio,
department,
time_val,
direction,
ROW_NUMBER() OVER (
PARTITION BY hoz_organ
ORDER BY time_val DESC, id DESC
) as rn
FROM scud_events_raw
WHERE log_date = ?
)
SELECT
hoz_organ,
fio,
department,
time_val,
direction
FROM RankedEvents
WHERE rn = 1
"""
if only_inside:
query += " AND direction = 'IN'"
query += " ORDER BY fio ASC;"
cursor.execute(query, (date_str,))
rows = cursor.fetchall()
results = [
{
"hoz_organ": r[0],
"fio": r[1],
"department": r[2],
"last_event_time": r[3],
"direction": r[4],
"status": "В здании" if r[4] == "IN" else "Вышел"
}
for r in rows
]
results.sort(key=lambda x: x["fio"].lower())
return results
+97
View File
@@ -0,0 +1,97 @@
"""
===============================================================================
FILE: core/repositories/zup_repo.py
ROLE: Репозиторий кадровых данных 1С:ЗУП, аномалий и базы знаний.
===============================================================================
"""
import pandas as pd
from typing import List, Dict, Any
from core.connection import get_connection
def save_staff_to_db(df_staff: pd.DataFrame, date_str: str) -> None:
if df_staff is None or df_staff.empty:
return
data = [
(date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
for _, r in df_staff.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data)
conn.commit()
def load_staff_from_db(date_str: str) -> pd.DataFrame:
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
def save_absences_to_db(df_absent: pd.DataFrame, date_str: str) -> None:
if df_absent is None or df_absent.empty:
return
data = [
(date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
for _, r in df_absent.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data)
conn.commit()
def load_absences_from_db(date_str: str) -> pd.DataFrame:
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
def save_anomalies_to_db(anomalies_list: List[Dict[str, Any]], date_str: str) -> None:
if not anomalies_list:
return
data = [(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) for a in anomalies_list]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data)
conn.commit()
def get_all_rules_from_db() -> List[str]:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT rule_text FROM ai_knowledge_base")
return [r[0] for r in cursor.fetchall()]
def add_rule_to_db(rule_text: str, added_by: str = "Human") -> None:
if not rule_text or not rule_text.strip():
return
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
conn.commit()
def get_department_synonyms_dict() -> Dict[str, str]:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
return {row[0]: row[1] for row in cursor.fetchall()}
def add_department_synonym_to_db(short_name: str, full_name: str) -> None:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)", (short_name.strip().lower(), full_name.strip().lower()))
conn.commit()
+196
View File
@@ -0,0 +1,196 @@
"""
===============================================================================
FILE: core/schema.py
PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: DDL-схемы таблиц, создание индексов и инициализация базы данных.
===============================================================================
"""
import logging
from core.connection import get_connection
logger = logging.getLogger("DB_SCHEMA")
def init_all_tables() -> None:
"""Инициализирует все таблицы и индексы системы."""
with get_connection() as conn:
cursor = conn.cursor()
# 1. Логи СКУД
cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
time_in TEXT,
first_activity TEXT DEFAULT '—',
time_out TEXT,
time_in_building TEXT,
is_present INTEGER NOT NULL,
anomaly_flag TEXT DEFAULT 'NONE',
snapshot_time TEXT DEFAULT NULL,
snapshot_id TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 1.1 Сырые физические события турникетов СКУД (для перекуров и оперативного статуса)
cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_events_raw (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_date TEXT NOT NULL,
time_val TEXT NOT NULL,
hoz_organ INTEGER NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
event_code INTEGER NOT NULL,
mode INTEGER NOT NULL,
door_index INTEGER DEFAULT 1,
direction TEXT NOT NULL, -- 'IN' или 'OUT'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 2. Кадровые реестры 1С
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_staff (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
absence_type TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 3. Аномалии, база знаний и синонимы
cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anomaly_date TEXT NOT NULL,
fio TEXT NOT NULL,
anomaly_type TEXT NOT NULL,
details TEXT NOT NULL,
human_status TEXT DEFAULT 'Pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS ai_knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_text TEXT UNIQUE NOT NULL,
added_by TEXT DEFAULT 'Human',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS department_synonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_name TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 4. Исключения и Кэш сопоставлений личностей (ИИ / Ручной)
cursor.execute("""
CREATE TABLE IF NOT EXISTS exceptions_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
value TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category, value)
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI', -- 'AI', 'MANUAL', 'EXACT'
status TEXT DEFAULT 'ACTIVE', -- 'ACTIVE', 'PROPOSED', 'REJECTED'
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
# 5. Узлы системного промпта, сессии, сообщения и задачи
cursor.execute("""
CREATE TABLE IF NOT EXISTS system_prompt_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt_name TEXT DEFAULT 'main_agent',
section_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
content TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(prompt_name, section_id, item_id)
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS session_states (
session_id TEXT PRIMARY KEY,
state_type TEXT NOT NULL,
pending_data TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
is_ephemeral INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT,
module TEXT DEFAULT 'general',
title TEXT NOT NULL,
priority TEXT DEFAULT 'MEDIUM',
status TEXT DEFAULT 'BACKLOG',
due_date TEXT,
user_id INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 6. Индексы
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mapping_scud ON person_identity_mapping(scud_fio);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_raw_events_date_hoz ON scud_events_raw(log_date, hoz_organ);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_raw_events_date_time ON scud_events_raw(log_date, time_val);")
conn.commit()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
reason
отгул
дежурство
обучение
экзамен в Ростехнадзоре
выходной день по ТД
1 reason
2 отгул
3 дежурство
4 обучение
5 экзамен в Ростехнадзоре
6 выходной день по ТД
+13 -14
View File
@@ -1,14 +1,13 @@
fio,reason,note
Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка
Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка
Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка
Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка
Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка
Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка
Ароян Станислав Месропович,Удаленная работа,Временная удаленка
fio,department,reason,date_from,date_to
Королёва Наталья Александровна,Все,Удаленная работа,,
Николаева Ирина Леонидовна,Все,Удаленная работа,,
Познякова Татьяна Сергеевна,Все,Удаленная работа,,
Чуб Александр Васильевич,Все,Удаленная работа,,
Шуличенко Иван Иванович,Все,Удаленная работа,,
Пухаренко Юрий Владимирович,Все,Удаленная работа,,
Пшеничный Виктор Петрович,Все,Удаленная работа,,
Незнанова Валерия Игоревна,Все,Удаленная работа,,
Ковалев Владимир Владимирович,Все,Удаленная работа,,
Кожокарь Татьяна Юрьевна,Все,Удаленная работа,,
Субетто Юлия Викторовна,Все,Удаленная работа,,
Чуркина Елена Геннадьевна,Все,Удаленная работа,,
1 fio department note reason date_from date_to
2 Королёва Наталья Александровна Все Постоянная удаленка Удаленная работа
3 Николаева Ирина Леонидовна Все Постоянная удаленка Удаленная работа
4 Познякова Татьяна Сергеевна Все Постоянная удаленка Удаленная работа
5 Софьин Никита Сергеевич Чуб Александр Васильевич Все Постоянная удаленка Удаленная работа
6 Чуб Александр Васильевич Шуличенко Иван Иванович Все Постоянная удаленка Удаленная работа
7 Шуличенко Иван Иванович Пухаренко Юрий Владимирович Все Постоянная удаленка Удаленная работа
8 Пухаренко Юрий Владимирович Пшеничный Виктор Петрович Все Постоянная удаленка Удаленная работа
9 Пшеничный Виктор Петрович Незнанова Валерия Игоревна Все Постоянная удаленка Удаленная работа
10 Незнанова Валерия Игоревна Ковалев Владимир Владимирович Все Постоянная удаленка Удаленная работа
11 Ковалев Владимир Владимирович Кожокарь Татьяна Юрьевна Все Постоянная удаленка Удаленная работа
12 Кожокарь Татьяна Юрьевна Субетто Юлия Викторовна Все Постоянная удаленка Удаленная работа
13 Субетто Юлия Викторовна Чуркина Елена Геннадьевна Все Постоянная удаленка Удаленная работа
Ароян Станислав Месропович Временная удаленка Удаленная работа
+288
View File
@@ -0,0 +1,288 @@
# Changelog
Все важные изменения проекта документируются в этом файле.
## [Unreleased]
### ✨ Добавлено (Added)
- **Изолированный домен работы с документами (`services/office/`):**
- Создан специализированный пакет для постраничной обработки многостраничных PDF и сканов без ограничений контекста диалога.
- Автоматическая сборка распознанного текста в файл Microsoft Word (`.docx`) со стандартизированным оформлением.
- Интеграция с роутером чата и выдача контрастной карточки скачивания документа.
### 📝 Запланировано (Planned)
- **Двухфазный OCR-конвейер рукописных виз и резолюций (`services/office/`):**
- Рендеринг сканов в высоком разрешении (`250–300 DPI`) для точного захвата линий чернил.
- Фаза 2 (Refiner на `qwen2.5:14b`): автоматическое устранение артефактов OCR, склейка абзацев и преобразование списков в таблицы Word.
- Распознавание рукописных виз «Согласовано», подписей и дат руководства с выделением в блок `[Резолюция: ...]`.
## [3.4.0] — 2026-09-25
### ✨ Добавлено (Added)
- **Оперативная On-Demand генерация отчетов из сайдбара:**
- Мгновенная сборка книги «Ежедневная сводка» по последнему зафиксированному в SQLite срезу СКУД (без ожидания внешнего опроса Орион).
- Строгое разграничение временных рамок отчетов: «Сводка» формируется на текущую смену, а «Детальный» и «Упрощенный» отчеты — строго за прошедшую смену (ВЧЕРА / прошлая пятница) по итоговому срезу `Y`.
- **Фоновая полудневная синхронизация 1С (cron):**
- Интеграция вызова `copy_1c_files_from_share()` и опроса кадровых отсутствий из базы ЗУП (`ACCOUNT-01`) в цикл получасовых срезов.
### 🛡️ Исправлено (Fixed)
- **Ошибка 404 при скачивании отчетов (`download_report_direct`):**
- Добавлен рекурсивный поиск файлов в подпапках `output/reports/{ГОД}/{МЕСЯЦ}/` и декодирование процент-кодированных кириллических имен из URL.
- **Ошибки 500 и 0 по штату 1С при создании сводки:**
- Реализован каскадный fallback: если файл штата на текущий день еще не выложен на шару, данные штата берутся из последнего сохраненного среза `zup_staff` в SQLite (устранено ложное попадание всех сотрудников в «Нет в ЗУП»).
- Исправлены `NameError` импортов (`load_best_snapshot_for_date`, `load_1c_data_smart`).
- **Сбои инспекции срезов (`OperationalError: no such column: hoz_organ`):**
- Переход на динамический `SELECT *` в роутере инспекции срезов и расчет реального времени в здании до момента фиксации снапшота.
## [3.3.0] — 2026-09-24
### ✨ Добавлено (Added)
- **Генератор «Упрощенного отчета» (`services/reports/simplified_builder.py`):**
- Формирование суточного файла `Упрощенный отчет за ДД.ММ.ГГГГг..xlsx` с выгрузкой на сетевую шару параллельно с детальным отчетом.
- Сокращение полных ФИО до эталонного формата с инициалами (`Иванов И.И.`).
- Подстановка официальных текстовых заглушек СКУД `Нет входа (0:00)` и `Нет выхода (23:59)` при отсутствии отметок.
- Алфавитная сортировка по подразделениям и сотрудникам, форматирование сетки `Calibri 11` с числовым форматом времени `h:mm`.
- **Разделы сводки «Не приняты на работу» и «Нет пропуска»:**
- В `services/scud_etl/merger.py` и `services/reports/svodka_builder.py` выделена категория физлиц, присутствующих в СКУД, но еще не оформленных в 1С:ЗУП (`not_hired_yet`).
- Сотрудники штата без карт доступа теперь явно направляются в блок «Нет пропуска» (`no_scud_pass`).
### 🔧 Изменено (Changed)
- **Исключение не принятых на работу из табеля:**
- В `services/reports/otchet_builder.py` сотрудники без проведенного приказа о приеме в 1С исключены из таблицы детального суточного отчета за вчера.
- **Восстановление аббревиатур подразделений:**
- В `merger.py` восстановлен приоритет компактных названий отделов из базы СКУД Орион (`ОАН`, `ПУ`, `ОИЗ`, `ОА`, `ИГТЛЛ` и др.) взамен длинных кадровых формулировок 1С.
- **Инфраструктура нового сервера (Debian 12):**
- Исправлены пути виртуального окружения `/home/apushkov/projects/scud_ai/venv/bin/python` в cron-скриптах.
- Настроено автоматическое CIFS-монтирование шары отчетов в `/etc/fstab` с правами пользователя `apushkov`.
### 🛡️ Исправлено (Fixed)
- **Сбой генерации детального отчета (`TypeError: NAN/INF not supported`):**
- В `otchet_builder.py` включен параметр книги `nan_inf_to_errors: True` и внедрена санитизация пустых ячеек `NaN`/`None`, исключающая аварийное завершение `write_number()`.
- **Предупреждения Pandas в логах:**
- Устранены ошибки `Boolean Series key will be reindexed to match DataFrame index` в `merger.py` за счет изоляции расчета масок от `df_staff_only`.
## [2.5.8] - 2026-09-10
### Добавлено (Added)
- **Двухконтурная физическая модель СКУД (Правый PERCo):**
- Поддержка проходов через оба турникета (`DoorIndex IN (1, 2)`) для сотрудников дворовых служб (отделы `ЭТО`, `ЛЦ` и др.).
- Реестр доступа к правому турникету в `exceptions_registry` с категориями `turnstile_fio` и `turnstile_departments`.
- Корректная склейка ФИО (`Name + FirstName + MidName`) в динамическом SQL-фильтре MS SQL.
- **Интерактивное модальное окно исключений:**
- Замена браузерного `prompt()` на модальную форму в едином Tailwind-стиле с автоподбором сотрудников из 1С:ЗУП (`staff-autocomplete`).
- Поле примечания/комментария для фиксации оснований допуска.
- **Точное локальное время создания срезов:**
- Явная фиксация времени хоста при записи срезов в `scud_logs.created_at`, устраняющая 3-часовое смещение UTC внутри контейнеров.
- **Декларативное подтверждение удаления срезов:**
- Добавление параметра `confirmed: boolean` в схему `TOOLS_SCHEMA` инструмента `db_delete_snapshots`, исключающее зацикливание подтверждений в LLM.
### Изменено (Changed)
- **Упразднение искусственного автозакрытия смен:**
- Полное отключение механизма `calculate_autoclose_time` по согласованию с отделом кадров. При отсутствии физической отметки на турникете статус «Нет выхода» сохраняется без искажения аналитики.
- **Математический расчет временных границ срезов:**
- Перевод формирования `@EndDate` в `services/scud_export.py` на цепочку функций `DATEADD` от `@StartDate`, устраняющий зависимость от регионального формата даты (`DMY` vs `MDY`).
## [Unreleased] - 2026-09-07
### Добавлено
- Механизм Gemini-скроллинга диалогового окна: функция `scrollToUserMessageTop` в `core.js` автоматически поднимает отправленный вопрос к верхнему срезу экрана, скрывая громоздкие предыдущие ответы.
- CSS-отступ `padding-bottom: 80vh` у `#chat-messages-container`, гарантирующий свободное вертикальное пространство для прокрутки без сдвига строки ввода.
- Пункт 2.9 системного промпта, жестко обязывающий модель вызывать нативный инструмент `db_get_rules` при запросах Базы Знаний и регламентов вместо текстовой имитации.
### Запланировано
- Рефакторинг интерпретации аппаратных кодов турникетов PERCO в `services/scud_export.py`: разделение логики разрешения прохода (`Event 28`) и факта проворота створки (`Event 32`), приоритет направления по `log.Mode` (1 — вход, 2 — выход) для устранения фантомных выходов.
- Создание DDL-схемы и таблицы `scud_events_raw` в SQLite для гранулярного хранения всех внутридневных перемещений сотрудников (база для учета перекуров и инструмента «Кто в здании»).
## [0.9.5] - 2026-08-28
### Добавлено
- Механизм отказоустойчивого сохранения отчетов `safe_close_workbook` в `services/excel_exporter.py`: при блокировке файла открытым процессом Excel создается резервный файл с таймстемпом вида `..._timestamp.xlsx`.
### Изменено
- Экспорт всех Excel-документов (`services/scud_export.py`, `services/excel_exporter.py`) полностью переведен с `openpyxl` на `xlsxwriter`.
- Скорректирована верстка детального отчета: столбец «Подразделение» выровнен строго по центру, колонка «ФИО» расширена на 10% (ширина 33), а избыточная ширина столбцов «Первая активность» и «Отклонение от нормы» уменьшена до 11 пунктов для корректного размещения на экране без горизонтальной прокрутки.
- В модуле сетевой синхронизации `services/share_copier.py` вызов `shutil.copy2` заменен на `shutil.copyfile`, что устранило ошибки метаданных `[Errno 1] Operation not permitted` при монтировании SMB-шары.
### Исправлено
- Устранена системная ошибка структуры OpenXML (`errorXXXXXX_01.xml`, «Обнаружена ошибка в части содержимого книги...»), возникавшая при открытии сгенерированных отчетов в MS Excel.
- Восстановлены сворачиваемые интерактивные категории с кнопками `[+]` / `[-]` в файле ежедневной сводки (`outline_settings(symbols_below=False)`).
## [Unreleased] - 2026-08-28
### Fixed
- **Генерация Excel-книг (`services/excel_exporter.py`, `services/scud_export.py`):** полный перевод экспорта с `openpyxl` на `xlsxwriter`, что устранило хроническую ошибку структуры OpenXML («В книге обнаружено нечитаемое содержимое...»).
- **Группировки в Сводке:** восстановлено корректное отображение интерактивных кнопок `[+]`/`[-]` и сворачивание категорий по умолчанию (`outline_settings`, `level=1`, `collapsed=True`).
- **Верстка и пропорции Детального отчета:** центрирование столбца «Подразделение», калибровка ширины колонок «ФИО» (+10%), «Первая активность» и «Отклонение от нормы» для плотного размещения таблицы на одном экране без горизонтальной прокрутки.
- **Безопасное сохранение при блокировках:** внедрена функция `safe_close_workbook` для фонового сохранения резервных копий с timestamp, если файл открыт пользователем в Excel.
- **Сетевое копирование 1С (`services/share_copier.py`):** замена `shutil.copy2` на `shutil.copyfile` для устранения сбоя `[Errno 1] Operation not permitted` при работе с SMB/CIFS-шарой.
## [Unreleased] - 2026-08-28
### Fixed
- **Инкремент Snapshot ID (`core/repositories/scud_repo.py`):** заменена строковая сортировка `ORDER BY snapshot_id DESC` на целочисленный парсинг суффиксов, что устранило залипание номеров снапшотов (`-002`).
### Added
- **Режим тихих почасовых срезов (`--export-only`):** быстрый экспорт данных СКУД напрямую в SQLite без построения отчетов для интеграции в cron.
- **Интерактивный справочник CLI (`main_etl.py`):** полный баннер справки по всем аргументам (`-h`, `--help`, `help`).
- **Автоматическая ротация промежуточных срезов (`services/snapshots/retention.py`):** ночная очистка устаревших почасовых срезов старше 2 дней с сохранением полуденного (`13:00`) и итогового `Y` (`23:59:59`).
## [Unreleased] - 2026-08-28
### Added
- **Smart Snap-to-Grid Finder (`services/snapshots/finder.py`):** интеллектуальный поиск ближайшего среза СКУД в SQLite с допуском ±20 минут и поддержкой On-Demand экспорта из MS SQL при отсутствии готового среза.
- **Параметр `--time` в `main_etl.py`:** возможность формирования оперативной сводки на произвольное время суток.
- **Раздельные генераторы отчетов:**
- `services/scud_etl/svodka_generator.py` — оперативная сводка за сегодня с группировками.
- `services/scud_etl/otchet_generator.py` — суточный детальный отчет за вчера по финишному срезу `Y` (23:59:59) с учетом рабочего норматива 8.5 ч.
### Changed
- **Фиксация финишного среза `Y` на `23:59:59`:** обновлено время снапшота в `services/scud_export.py`, `core/repositories/scud_repo.py` и `services/scud_etl/pipeline.py` с сохранением обратной совместимости со срезами на `22:00:00`.
- **Путь виртуального окружения в `run_cron_etl.sh`:** скорректирован на актуальный `/home/puh/projects/scud_ai/venv/bin/python`.
- **Игнорирование временных файлов SQLite:** добавлены `*.db-shm` и `*.db-wal` в `.gitignore`.
## [3.2.0] — 2026-08-27
### 🧠 Интеллектуальный арбитраж личностей, агрегация мульти-пропусков и автозакрытие смен
#### ✨ Добавлено:
- **Кэш сопоставлений личностей (`person_identity_mapping`)**:
- Создана таблица в SQLite (`core/schema.py`) для постоянного хранения подтвержденных связок между сырыми ФИО СКУД и эталонными ФИО 1С:ЗУП.
- Поддержка мгновенного извлечения проверенных сопоставлений в `merger.py` (время выборки < 1 мс).
- **Агрегация дубликатов пропусков и мульти-учеток СКУД (`merger.py`)**:
- Функция `aggregate_scud_by_person` объединяет все карты/записи одного физлица: фиксирует самый ранний вход `min(valid_ins)`, самый поздний выход `max(valid_outs)` и итоговый статус присутствия.
- Исключено ложное попадание сотрудников с несколькими пропусками (например, Кондрашова Е.В.) в категорию «неизвестно».
- **Интеллектуальный выбор ставки совместителей 1С (`merger.py`)**:
- Функция `select_best_zup_position` сопоставляет множественные должности одного сотрудника в 1С со СКУД и подтягивает ставку того подразделения, где человек находится физически.
- **Автозакрытие незакрытых смен по Правилу 8.5ч (`excel_exporter.py`)**:
- При наличии утреннего входа и отсутствии вечернего выхода у офисных сотрудников в детальном отчете за вчера расчетный выход ставится как `Вход + 8ч 30мин`, норма 8 часов и нулевое отклонение `0:00` (вместо ложного штрафа `-8:00`).
- **Теневой ИИ-арбитраж нераспознанных ФИО (`text_reporter.py`)**:
- Модуль `find_ai_identity_suggestions` выявляет нечеткие совпадения и опечатки операторов СКУД, выводя рекомендации для кадровой службы в блоке «💡 Предложения ИИ по сопоставлению ФИО».
- **Расширение CLI-утилиты (`scripts/db_cli.py`)**:
- Добавлены команды управления связками: `python scripts/db_cli.py mapping [list|add|del]`.
#### 🔧 Изменено:
- **`services/scud_etl/anomaly_detector.py`**:
- Добавлено авто-детектирование задвоенных карточек СКУД (`DUPLICATE_SCUD_CARD`) с передачей информации в ИИ-сводку.
- **`main_etl.py`**:
- Восстановлена передача сырых датасетов (`raw_scud_df`, `raw_staff_df`, `raw_absent_df`) в ИИ-аудитор и генерация диагностического дампа `СКУД_Сырые_данные_ДД.ММ.ГГГГ.xlsx`.
---
## [3.1.0] — 2026-08-25
### 🛡️ Реляционный реестр исключений, Белый список и отказоустойчивость выгрузок
#### ✨ Добавлено:
- **Реляционный реестр исключений (`exceptions_registry`)**:
- Перенос исключений (`departments`, `positions`, `fio`, `position_keywords`) и белого списка (`include_fio`) из статического JSON в базу данных SQLite (`core/schema.py`).
- Репозиторий `services/exceptions_repo.py` с функциями CRUD и авто-миграцией из `exceptions.json`.
- **Белый список (`include_fio`)**:
- Поддержка явного включения сотрудников в расчет контроллинга в обход должностных и отдельских фильтров (Тарасенко А.А., Журиков М.Н.).
- **Управление исключениями в CLI (`scripts/db_cli.py`)**:
- Добавлены команды `exceptions list`, `exceptions add -c ... -v ...`, `exceptions del`, `exceptions sync`.
- **Резервный загрузчик кадровых отсутствий (`data_loader.py`)**:
- Реализован каскадный fallback: `MS SQL 1C:ЗУП` (таймаут 30с) $\rightarrow$ резервный парсинг скопированных Excel-файлов `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`.
#### 🔧 Изменено:
- **Структура отчетов Excel (`excel_exporter.py`)**:
- Изменен базовый путь сохранения отчетов на `output/reports/ГОД/МЕСЯЦ/`.
- В Ежедневной сводке блок «В том числе на удаленной работе» вынесен под «Итого на работе», а блок «Исключения» размещен в самом низу с поддержкой группировки.
- Добавлена защита от блокировок Excel (`PermissionError`/`OSError`): при занятости файла создается резервная копия с временной меткой.
- **Подключение к СКУД (`services/scud_export.py`)**:
- Актуализирован адрес сервера MS SQL Орион (`172.16.200.147\SQL`).
- Добавлена защита инициализации файлового логгера от ошибок доступа прав пользователей.
---
## [3.0.0] — 2026-08-21
### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture)
#### Добавлено:
- **Пакет ядра `core/`:**
- `core/connection.py`: централизованный менеджер SQLite-подключений с поддержкой WAL-режима и row_factory.
- `core/schema.py`: единая точка инициализации схемы данных и индексов.
- `core/repositories/`: низкоуровневые репозитории `scud_repo.py` и `zup_repo.py`.
- **Доменные сервисы `services/`:**
- `services/tasks/`: полная изоляция логики задач (CRUD, Markdown-экспорт, генерация `ROADMAP.md`).
- `services/prompts/`: доменный сервис системного промпта, поддержка превью, генерация HTML-Diff и пакетное удаление узлов (`BATCH_DELETE`).
- `services/snapshots/`: бизнес-логика срезов СКУД с защитой финишных Y-снапшотов.
- `services/knowledge/`: сервис базы знаний правил и синонимов отделов.
- `services/scud_etl/`: модульный ETL-конвейер (`pipeline.py`, `anomaly_detector.py`, `merger.py`, `sql_queries.py`).
- **ИИ-оркестратор `modules/ai_engine/`:**
- Декомпозиция монолитного `agent.py` на узкие обработчики `handlers/` и динамический контекст-билдер `context_builder.py`.
- Двухконтурная обработка: нативный Function Calling + семантический страхующий инжектор (`tool_injector.py`).
- Интеллектуальный Topic Drift менеджер с вежливым напоминанием на 3-й шаг и автоочисткой эфемерных сообщений на 4-й.
- **Интерфейс и UI:**
- Кнопка «✏️ Редактировать» прямо в карточке просмотра системного промпта с инлайн-редактором.
#### Исправлено:
- Устранена утечка системного промпта в постоянный контекст (`chat_messages`).
- Исправлены ошибки несоответствия сигнатур (`TypeError: create_prompt_preview`, `save_full_prompt_draft`, `get_snapshots_registry`) в Fast-Path.
- Устранена галлюцинация слов-паразитов («Почемучка», «Почемучто») на уровне валидации ответа.
- Исправлено устаревшее противоречивое правило 2.3 в базе данных, блокировавшее интерактивное удаление задач.
#### Удалено:
- Устаревшие файлы-дубли: `modules/web_api/llm/db/db_tasks.py`, `db_snapshots.py`, `prompt_merger.py`.
---
## [2026-08-19] — Инлайн-редактор задач, интеллектуальный ContextManager и стабилизация Function Calling
### ✨ Добавлено
* **Инлайн-редактирование задач (`task_widget.js` / `tasks.py`)**:
- Добавлена кнопка редактирования (`✏️`) на каждой карточке задачи.
- Карточка трансформируется в инлайн-форму с тремя полями: текст задачи, HTML5-пикер календаря планируемого срока и выпадающий список важности (`LOW`, `MEDIUM`, `HIGH`, `CRITICAL`).
- Добавлен REST API эндпоинт `PATCH /api/v1/tasks/{task_id}` с поддержкой комплексного обновления полей в SQLite.
- В карточки выведено отображение даты создания задачи.
* **Интеллектуальный ContextManager (`context_manager.py`)**:
- Служебные запросы и карточки инструментов сохраняются синхронными парами (`is_ephemeral = 1`) и атомарно удаляются при завершении операций.
- Свободные диалоги оператора (включая темы Topic Drift) сохраняются в постоянную историю (`is_ephemeral = 0`) и не стираются при закрытии сессий промпта/задач.
* **Поддержка Y-снапшотов (`db_snapshots.py`)**:
- Исправлен SQL-запрос `db_get_snapshots` для точного извлечения ночных и фоновых срезов с префиксом `Y` (например, `Y20260817-003`).
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**:
- Полностью удалены эвристические текстовые фильтры и принудительные сортировки инструментов.
- Настроен нативный возврат и исполнение инструментов без зацикливания модели.
- Устранена ошибка `UnboundLocalError` при вызове справочных инструментов.
* **`modules/web_api/llm/schemas.py`**:
- Очищены описания инструментов от агрессивных директив и капса, стабилизирована селективность Function Calling для Qwen 2.5.
* **`modules/web_api/llm/core/ollama_client.py`**:
- Оптимизированы параметры генерации для Function Calling (`repeat_penalty: 1.0`, `presence_penalty: 0.0`, `temperature: 0.0`), что исключило спам несколькими вызовами инструментов на один запрос.
* **`modules/web_api/static/js/chat/task_widget.js`**:
- Фильтр по умолчанию переведен на вкладку **«В работе»** (`IN_PROGRESS`).
---
## [2026-08-18] — Двухфазный жизненный цикл сессий, двусторонний Diff и Topic Drift Guard
### ✨ Добавлено
* **Двусторонний клиентский Diff (`core.js`)**: Реализован алгоритм сравнения черновика с baseline-промптом. Удаленные строки остаются на экране с красным зачеркиванием `[УДАЛЕНИЕ]`, а добавленные/измененные строки выделяются красной рамкой с авто-отступами.
* **Topic Drift Guard (`agent.py`)**: Внедрен двухуровневый контроль смены темы:
- Мгновенный сброс стейта и зачистка эфемерных сообщений при вызове сторонних инструментов (задачи, снапшоты, база).
- Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`.
* **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий.
---
## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling
### ✨ Добавлено
* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`.
* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling.
---
## [2026-08-15] — Реляционная архитектура системного промпта
### ✨ Добавлено
* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`).
* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта.
* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`.
---
## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога
### ✨ Добавлено
* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков.
* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций.
* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия.
@@ -0,0 +1,185 @@
# **PROJECT BRAIN: SCUD Orion AI & Context API (Master Technical & Architectural Manifesto)**
**Версия документа:** 5.0 (Ultimate Developer & LLM Context Engine)
**Назначение:** Исчерпывающий контекстный дамп проекта для немедленного продолжения разработки в любой передовой LLM без потери исторического контекста, с полным пониманием бизнес-логики, схем баз данных, подводных камней и нерешенных задач.
## ---
**1\. Архитектурный паспорт и инфраструктурный профиль**
| Компонент | Параметры и стек |
| :---- | :---- |
| **Среда и ОС** | Linux Docker Home (Debian/Ubuntu), Python 3.11/3.12 в виртуальном окружении venv, Git/Gitea. Разработка ведется через VS Code Remote-SSH. |
| **Локальная СУБД проекта** | SQLite (data/scud\_orion\_ai.db), режим PRAGMA journal\_mode \= WAL, PRAGMA synchronous \= NORMAL, PRAGMA foreign\_keys \= ON. |
| **СКУД Источник (MSSQL)** | MS SQL Server 172.16.31.221\\SQL (база Orion-14.01.21-1), драйвер ODBC Driver 18 for SQL Server (TrustServerCertificate=yes, Encrypt=no). Доступ через pyodbc. |
| **1С:ЗУП 3.1 Источник (MSSQL)** | MS SQL Server ACCOUNT-01 (база ZUP30, пользователь scud\_reader). Прямое чтение таблиц dbo.\_Reference299 (сотрудники) и dbo.\_InfoRg16921 (состояния отсутствий). Резервная Samba-шара: /mnt/scud\_share. |
| **Локальный контур LLM** | Ollama на http://192.168.11.3:11434 (GPU NVIDIA RTX 5070 Ti 16GB). Модель qwen2.5:14b (основной агент, Function Calling, анализ аномалий), qwen2.5vl:7b-q8\_0 (OCR документов/картинок). |
| **Web API & Фронтенд** | FastAPI (Uvicorn), JWT (HS256, 30 дней), Passlib (bcrypt). Фронтенд: Single Page Application (HTML5, Tailwind CSS, Vanilla JS) с Generative UI виджетами. |
## ---
**2\. Энциклопедия подводных камней, инцидентов и принятых инженерных решений**
### **2.1. Галлюцинации LLM и переход к парадигме Python-Driven Diff**
> * **Проблема:** Попытка передать списки в 300 ФИО напрямую в промпт модели с командой «сравни списки» приводила к потере контекста, выдуманным опечаткам (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*) и плейсхолдерам ('Васильев Л.И.', ...).
> * **Решение:** Утверждена строгая гибридная схема:
1. **Детерминированный слой (Python / Pandas / SQL):** читает файлы, нормализует строки, агрегирует совмещения и мгновенно находит 95%+ точных соответствий.
2. **Интеллектуальный слой (Qwen 2.5:14b):** вызывается *только на спорный несовпавший остаток* (1–5 записей) в виде строгого JSON-интерфейса Entity Resolution.
### **2.2. Символьная нормализация и кадровые совмещения (Кейс Балаевой О.Г.)**
> * **Проблема символов:** Латинские омоглифы (буквы клавиатуры a, c, e, o, p, x, y вместо русских), разделители 1С в скобках ((осн.), (совм.), (внешн.)), неразрывные пробелы \\xa0, буква ё и случайный ввод цифры 0 вместо буквы О.
> * **Решение (normalize\_fio):** Очистка скобок через регулярное выражение re.sub(r'\\(.\*?\\)', '', fio), замена латиницы через str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ'), замена ё-\>е и удаление лишних пробелов.
> * **Коллизия 270 vs 271:** В СКУД 270 физических лиц (один пропуск на человека), в 1С — 271 строка. Сотрудник **Балаева Ольга Геннадьевна** числится на двух ставках в разных отделах (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*).
> * **Решение:** Внедрена группировка датафрейма 1С по fio\_clean с агрегацией должностей и подразделений через разделитель " / ".
### **2.3. Защита от коллизий полных тёзок и ложных связок однофамильцев (Кейс Семеновых)**
> * **Инцидент:** Неограниченная строгими правилами LLM связала двух разных сотрудниц — *«Семенову Юлию Борисовну»* (работает) и *«Семенову Дину Дмитриевну»* (в отпуске), посчитав разные имена опечаткой из\-за одинаковой фамилии. В результате Юлии Борисовне ошибочно был приписан статус отпуска.
> * **Двухконтурный барьер:**
1. **В промпте:** Запрет объединения однофамильцев с разными именами/отчествами.
2. **В Python:** Проверка совпадения первых букв имени и отчества. При несовпадении сопоставление ИИ аннулируется с логированием ⚠️ \[Блокировка фейка\].
### **2.4. Вынесение исключений в exceptions.json и синонимы отделов**
> * Служебный персонал (отдел «ЭТО», уборщики производственных и служебных помещений, клинеры, дворники, гардеробщики) вынесен в exceptions.json.
> * Для сопоставления кратких аббревиатур СКУД и полных названий отделов 1С (*«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица department\_synonyms с автоматическим самообучением при работе человека (Human-in-the-Loop).
### **2.5. Разгадка феномена «исчезающих неизвестных» и аппаратный сбой СКУД 04.08.2026**
> * **Аномалия:** Утром 04.08.2026 на главном КПП зависли контроллеры входа. У 119 сотрудников не записалось утреннее событие (time\_in \= 'Нет входа'). Из них 57 человек были в отпусках/командировках по 1С. Оставшиеся 74 человека были на работе без утренней отметки. Однако вечерняя сводка показала всего 4–7 «неизвестных».
> * **Причина:** Скрипт агрегации видел дневные перемещения (обед, внутренние двери, туалет, вечерний выход) и автоматически проставлял Статус \= 'Присутствовал' и is\_present \= 1, несмотря на то, что колонка Начало\_дня оставалась 'Нет входа'. В итоге 70 человек тихо ушли в «Итого на работе», а системный сбой оборудования был замаскирован.
> * **Архитектурный фикс:**
1. Введена колонка **first\_activity («Первая активность»)** — вычисление MIN(TimeVal) AS FirstRawEvent по таблице pLogData без учета направления двери.
2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи ставится anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\.
3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): если доля таких аномалий превышает 5% смены (\>10 чел.), ИИ генерирует системный алерт о массовом сбое контроллеров КПП.
> * **Кейс Равина В.Э.:** Главный специалист официально находился в основном отпуске по 1С, утреннего входа на КПП не было, но зафиксирована активность в 14:11:06 и вечерний выход в 17:18:38. Система зарегистрировала истинную кадровую аномалию (физическое присутствие при официальном отпуске).
### **2.6. Система сквозных датированных снапшотов (срезов)**
> * **Формат ID:** \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного).
> * **Оптимизация:** Функция has\_yesterday\_final\_snapshot() проверяет наличие вчерашнего среза в SQLite: если он есть, повторный тяжелый запрос к MS SQL Орион пропускается.
> * **CLI-управление:** Поддержка запуска отчетов по любому историческому снапшоту через ключ \--snapshot \[ID\] и пропуск выгрузки через \--skip-export.
### **2.7. Web API, Generative UI и управление жизненным циклом сессий (Topic Drift & Ephemeral Buffer)**
> * **Проблема засорения контекста LLM:** Вывод объемных списков задач, системных промптов и таблиц срезов забивал окно диалога, вызывая галлюцинации и «залипание» модели на старых данных.
> * **Решение:**
* В chat\_messages введен флаг is\_ephemeral \= 1 для временных сообщений.
* Двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик idle\_turns.
* **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages().
* **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю») и выхода («нет, спасибо») без обращения к LLM (ответ за 0.05 сек).
* **Декларативный реестр (tool\_action\_registry):** Хранение шаблонов ответов, кнопок и настроек эфемерности в SQLite.
> * **Generative UI (Задачи):** Инструмент db\_get\_tasks возвращает TASK\_INTERACTIVE\_CARD, рендерящий на клиенте живой виджет задач с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API.
## ---
**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)**
| Таблица | Назначение и бизнес-логика | Структура полей |
| :---- | :---- | :---- |
| **scud\_logs** | Логи проходов СКУД, расчетное время в здании и снапшоты. | id INTEGER PK AUTOINCREMENT, log\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, time\_in TEXT, first\_activity TEXT DEFAULT '—', time\_out TEXT, time\_in\_building TEXT, is\_present INTEGER, anomaly\_flag TEXT DEFAULT 'NONE', snapshot\_time TEXT, snapshot\_id TEXT, created\_at TIMESTAMP |
| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id INTEGER PK AUTOINCREMENT, snapshot\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, created\_at TIMESTAMP |
| **zup\_absences** | Документы отклонений 1С (отпуска, больничные, командировки, декреты). | id INTEGER PK AUTOINCREMENT, absence\_date TEXT, fio TEXT, fio\_clean TEXT, absence\_type TEXT, created\_at TIMESTAMP |
| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id INTEGER PK AUTOINCREMENT, anomaly\_date TEXT, fio TEXT, anomaly\_type TEXT, details TEXT, human\_status TEXT DEFAULT 'Pending', created\_at TIMESTAMP |
| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id INTEGER PK AUTOINCREMENT, rule\_text TEXT UNIQUE, added\_by TEXT DEFAULT 'Human', created\_at TIMESTAMP |
| **system\_prompts** | Версионные системные промпты агента (активный по is\_active=1). | id INTEGER PK AUTOINCREMENT, name TEXT, prompt\_text TEXT, is\_active INTEGER DEFAULT 1, updated\_at TIMESTAMP |
| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id INTEGER PK AUTOINCREMENT, tool\_name TEXT UNIQUE, category TEXT, bypass\_llm INTEGER, success\_template TEXT, follow\_up\_question TEXT, action\_type TEXT, buttons\_json TEXT, is\_active INTEGER, is\_ephemeral INTEGER, updated\_at TIMESTAMP |
| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id TEXT PRIMARY KEY, state\_type TEXT, pending\_data TEXT, updated\_at TIMESTAMP |
| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id INTEGER PK AUTOINCREMENT, session\_id TEXT, role TEXT, content TEXT, is\_ephemeral INTEGER DEFAULT 0, created\_at TIMESTAMP |
| **tasks** | Реестр задач операторов с привязкой к user\_id. | id INTEGER PK AUTOINCREMENT, task\_id TEXT, module TEXT, title TEXT, priority TEXT, status TEXT, due\_date TEXT, user\_id INTEGER, created\_at TIMESTAMP |
| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id INTEGER PK AUTOINCREMENT, username TEXT UNIQUE, password\_hash TEXT, is\_admin INTEGER, full\_name TEXT, created\_at TIMESTAMP |
| **department\_synonyms** | База соответствия аббревиатур СКУД полным названиям отделов 1С. | id INTEGER PK AUTOINCREMENT, short\_name TEXT UNIQUE, full\_name TEXT, created\_at TIMESTAMP |
| **system\_reference** | Справочник команд и примеров промптов для операторов. | id INTEGER PK AUTOINCREMENT, category TEXT, title TEXT, example\_prompt TEXT, description TEXT |
## ---
**4\. Архитектурная структура каталогов**
scud\_ai/
├── config.py \# Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL
├── exceptions.json \# Декларативные фильтры исключений (ОВК, уборщики, подрядчики)
├── main\_etl.py \# Оркестратор ETL: экспорт СКУД/1С \-\> SQLite \-\> Excel/MD отчеты
├── project\_code\_snapshot.md \# Полный монолитный исходный код всех файлов репозитория
├── core/
│ └── database.py \# Драйвер SQLite (WAL), функции сохранения/загрузки срезов
├── services/
│ ├── scud\_export.py \# Прямой SQL-экспорт из MS SQL «Орион Pro»
│ ├── zup\_extractor.py \# Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1
│ ├── data\_loader.py \# Загрузка данных, нормализация ФИО, расчет первой активности
│ ├── data\_validator.py \# Проверка свежести и наличия файлов за Вчера и Сегодня
│ ├── excel\_exporter.py \# Генерация сводки и детального отчета в Excel (openpyxl)
│ ├── text\_reporter.py \# Построение итогового Markdown-отчета через Qwen 2.5
│ ├── ai\_verifier.py \# Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД
│ ├── feedback\_loop.py \# Интерактивный консольный модуль Human-in-the-Loop
│ ├── knowledge\_base.py \# Интерфейс работы с базой знаний правил
│ └── share\_copier.py \# Резервное скачивание файлов 1С с шары /mnt/scud\_share
├── scripts/
│ ├── db\_cli.py \# CLI-утилита управления БД, снапшотами, дампами и сессиями
│ ├── fix\_snapshots.py \# Скрипт переиндексации и нормализации снапшотов
│ ├── init\_tool\_registry.py \# Инициализация декларативного реестра инструментов
│ └── diagnostics/
│ ├── inspect\_db.py \# Диагностика структуры таблиц и количества записей
│ ├── inspect\_ephemeral.py \# Анализ распределения постоянных и эфемерных сообщений
│ ├── inspect\_files.py \# Аудит файлов и размеров на диске
│ └── show\_tree.py \# Вывод дерева каталогов
├── modules/web\_api/
│ ├── main.py \# Точка входа FastAPI приложения, роутеры и раздача статики
│ ├── routers/ (admin, auth, chat, tasks) \# REST эндпоинты
│ ├── llm/
│ │ ├── agent.py \# Координатор диалога, Tool Calls и Topic Drift Guard
│ │ ├── schemas.py \# Описание JSON-схем инструментов для Ollama
│ │ ├── file\_parser.py \# Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)
│ │ ├── db\_tools.py \# Фасадные вызовы функций БД для агента
│ │ ├── core/ (calendar\_utils, fast\_path, ollama\_client, prompt\_merger, tool\_injector)
│ │ └── db/ (connection, db\_chat, db\_prompts, db\_snapshots, db\_tasks)
│ └── static/ (index.html, css/styles.css, js/app.js, js/auth.js, js/chat.js, js/tasks.js)
└── docs/
└── development\_roadmap.md \# Стратегическая дорожная карта и архитектурный бэклог
## ---
**5\. Справочник команд CLI (HOWTO)**
### **ETL Пайплайн (main\_etl.py):**
> * python main\_etl.py — стандартный дневной запуск (экспорт из MS SQL, сохранение снапшота, генерация отчетов).
> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL.
> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота.
> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG).
### **CLI базы данных (scripts/db\_cli.py):**
> * python scripts/db\_cli.py stats — статистика по количеству строк во всех таблицах БД.
> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (свежие сверху).
> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — инспекция логов СКУД и экспорт в Excel.
> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП.
> * python scripts/db\_cli.py anomalies — история выявленных аномалий.
> * python scripts/db\_cli.py rules — правила базы знаний компании.
> * python scripts/db\_cli.py prompts — системные промпты из system\_prompts.
> * python scripts/db\_cli.py tools — декларативный реестр действий инструментов и кнопок.
> * python scripts/db\_cli.py sessions — активные сессионные стейты и висящие черновики.
> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID.
> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за день.
> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel.
## ---
**6\. Стратегическая дорожная карта и нерешенные задачи (Roadmap & Backlog)**
> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):**
* **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP.
* **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений.
* **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py.
> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):**
* Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData.
* Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут).
* Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку.
> 3. **Интерактивный Task Planner & Roadmap Engine:**
* Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks.
> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):**
* Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст.
> 5. **Автоматизация расписания срезов и Telegram-алерты:**
* Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time.
* Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram.
@@ -0,0 +1,223 @@
# **SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт, разбор всех инцидентов и технический контекст проекта**
**Версия документа:** 4.0 (Исчерпывающий монолитный контекстный паспорт для новой сессии)
**Стек технологий и инфраструктура:** Python 3.11+, FastAPI (Uvicorn), SQLite (WAL mode, PRAGMA synchronous=NORMAL, внешние ключи ON), Ollama REST API (Qwen 2.5:14b — основной текстовый агент и классификатор, Qwen 2.5-VL:7b-q8\_0 / Qwen 3-VL:8b — OCR и зрение), MS SQL Server 2019/2022 (СКУД «Орион Pro» ЗАО НВП «Болид», 1С:ЗУП 3.1 через pyodbc / ODBC Driver 18), сетевая Samba-шара (/mnt/scud\_share), Git/Gitea, HTML5 / Vanilla JS / Tailwind CSS.
## ---
**1\. Исходный бизнес-контекст, роли систем и фундаментальные задачи**
Проект решает комплексную проблему контроллинга трудовой дисциплины, автоматического сопоставления кадровых фактов и защиты от рассинхронизации мастер-данных на предприятии со штатом \~270–276 сотрудников.
### **Источники первичных данных:**
> * **СКУД «Орион Pro» (Болид):** База данных MS SQL Server (SERVER: 172.16.31.221\\SQL, DB: Orion-14.01.21-1). Основные таблицы:
* pLogData — журнал всех сырых физических транзакций турникетов, калиток и внутренних дверей. Ключевые поля: TimeVal (время сервера), DeviceTime (время контроллера), HozOrgan / Hrk (ID сотрудника), Event (код события: 26, 28, 32 — вход; 27, 29, 33 — выход; 54, 55, 64, 65 — внутренние считыватели), Mode (направление: 1 — IN, 2 — OUT), ReaderIndex, DoorIndex.
* pList — реестр физических лиц и пропусков (ФИО, табельный номер, статус архива).
* PDivision, PPost — справочники подразделений и должностей СКУД.
> * **1С:ЗУП 3.1 (Кадры):** База данных MS SQL Server (SERVER: ACCOUNT-01, DB: ZUP30).
* dbo.\_Reference299 — эталонный справочник сотрудников.
* dbo.\_InfoRg16921 — периодический регистр сведений состояний сотрудников (отпуска основные, больничные, командировки, декретные отпуска, отпуска без сохранения оплаты).
* Файловые выгрузки с сетевой шары /mnt/scud\_share: файлы Штат\_ДД\_ММ\_ГГГГ.xlsx (шапка 7–8 строк) и Отсутствия\_ДД\_ММ\_ГГГГ.xlsx (шапка 3–4 строки).
> * **Реестры статических исключений:** static\_reason\_workers.csv (реестр удаленщиков и постоянных разъездных сотрудников), exceptions.json (декларативный фильтр обслуживающего персонала и спец-отделов).
## ---
**2\. Подробнейшая пошаговая хронология разработки, эволюция идей и разбор ошибок**
### **Фаза 1: Первые эксперименты с LLM, выявление галлюцинаций и гибридная парадигма**
> * **Попытка передачи сырых списков в чат:** Первоначальная попытка скормить модели Qwen 2.5:14b список из сотен ФИО с инструкцией *«запомни список 1С»* показала, что у LLM нет постоянной памяти между сессиями. Модель теряла контекст, путала фамилии и выдавала ответы с троеточиями ('Васильев Л.И.', ...) или придумывала несуществующие строки (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*).
> * **Ошибки openpyxl vs pandas:** Первая генерация кода моделью использовала библиотеку openpyxl с построчным перебором ячеек, жестко зашитыми опечатками (поиск слова 'фиио') и падением по FileNotFoundError.
> * **Переход к детерминированному пайплайну:** Принято ключевое архитектурное решение — **LLM не считает математику и не фильтрует 300 строк текста**. Всю механическую работу (ETL, слияние, фильтрацию) выполняет быстрый детерминированный Python (pandas, SQL), а локальная LLM выступает исключительно в роли «умного ревизора» на этапе сопоставления сомнительного несовпавшего остатка (Diff).
### **Фаза 2: Нормализация ФИО, кодировки и коллизия совместителей (Кейс Балаевой О.Г.)**
> * **Символьные ловушки:**
* Латинские буквы-двойники (омоглифы a, c, e, o, p, x, y, случайно набранные в английской раскладке).
* Служебные суффиксы в скобках, добавляемые 1С ((осн.), (совм.), (внешн.)).
* Буквы ё/е, двойные пробелы, неразрывные пробелы \\xa0 и случайный ввод цифры 0 вместо буквы О.
> * **Функция normalize\_fio():** Разработан строгий конвейер очистки:
> `def normalize_fio(fio):`
> `if not fio or not isinstance(fio, str): return ""`
> `fio_clean = re.sub(r'\(.*?\)', '', fio) # Удаление скобок`
> `fio_clean = fio_clean.replace('\xa0', ' ')`
> `trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ')`
> `fio_clean = fio_clean.translate(trans)`
> `fio_clean = fio_clean.replace('ё', 'е').replace('Ё', 'Е')`
> `return " ".join(fio_clean.strip().split()).title()`
> * **Коллизия 270 vs 271:** В СКУД числилось 270 уникальных человек, а в 1С — 271 строка. Ручной разбор показал, что **Балаева Ольга Геннадьевна** занимает две ставки в 1С (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*) при одном физическом пропуске в СКУД.
> * **Решение:** Внедрена обязательная группировка штата 1С по fio\_clean с объединением должностей и подразделений через " / ".
### **Фаза 3: Защита Entity Resolution от однофамильцев (Инцидент с Семеновыми)**
> * **Сбой модели:** При передаче несовпавших ФИО в Qwen 2.5 модель ошибочно сопоставила *«Семенову Юлию Борисовну»* (которая была на работе) с *«Семеновой Диной Дмитриевной»* (находившейся в отпуске), посчитав это опечаткой в имени из\-за одинаковой фамилии. В результате Юлия Борисовна получила статус нарушителя («в отпуске, но пришла»).
> * **Двухконтурная защита:**
1. В системный промпт ИИ зашито категорическое правило: однофамильцы с разными именами/отчествами — строго разные люди, объединение запрещено.
2. В Python встроен дублирующий барьер: если фамилии совпадают, но инициалы/первые буквы имен различаются, связь ИИ принудительно аннулируется с логированием ⚠️ \[Блокировка фейка\].
### **Фаза 4: Декларативные исключения и синонимы отделов**
> * **Переход к exceptions.json:** Исключение клининга, дворников, гардеробщиков и хозяйственного персонала отдела «ЭТО» вынесено из кода в конфигурационный файл exceptions.json (поддержка списков departments, positions, fio, position\_keywords).
> * **База синонимов отделов (department\_synonyms):** Для сопоставления сокращений СКУД с длинными названиями 1С (например, *«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица SQLite, автоматически обучаемая при подтверждениях оператора.
### **Фаза 5: Аппаратный сбой СКУД 04.08.2026, разгадка «исчезающих неизвестных» и Первая активность**
> * **Анатомия сбоя:** Утром 04.08.2026 зависли регистраторы входа на главном КПП. У 119 сотрудников не записалось событие входа (time\_in \= 'Нет входа'). Из них 57 человек были официально закрыты документами 1С (отпуска, командировки). Оставшиеся \~74 человека реально пришли на работу, но утренней отметки не имели. Однако вечером сводка выдала всего 4–7 «неизвестных».
> * **Вскрытие механизма ошибки:** Было обнаружено, что скрипт агрегации видел дневные перемещения сотрудников (выходы на обед, считыватели туалетов, внутренние двери) и автоматически выставлял Статус \= 'Присутствовал' и is\_present \= 1\. Из-за этого люди попадали в категорию «Итого на работе», а массовый аппаратный сбой скрывался.
> * **Внедрение «Первой активности»:**
1. В базу данных и выгрузки Excel добавлена колонка **first\_activity («Первая активность»)** — минимальное время любого события дня в здании (MIN(TimeVal) AS FirstRawEvent без учета направления двери).
2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи присваивается anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\.
3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): при превышении порога в 5% смены (\>10 человек) ИИ поднимает тревогу о массовом сбое контроллеров КПП.
> * **Кейс Равина В.Э.:** Сотрудник находился в официальном отпуске, утреннего входа не имел, но пришел после обеда (первая активность 14:11:06, выход 17:18:38). Система корректно зафиксировала истинную кадровую аномалию (физическое присутствие при официальном отпуске).
### **Фаза 6: Архитектурная миграция под Gitea и система срезов (снапшотов)**
> * **Безопасный рефакторинг:** Монолит разделен на слои (core/, services/, scripts/, modules/web\_api/) через git mv с сохранением истории коммитов в ветке refactor/modular-structure.
> * **Формат ID снапшота:** Введена схема \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного).
> * **Оптимизация экспорта:** Если в SQLite уже зафиксирован вчерашний срез с префиксом Y, повторный тяжелый SQL-запрос к СКУД Орион не выполняется (функция has\_yesterday\_final\_snapshot).
> * **Инспекция и CLI:** Написан инструмент scripts/db\_cli.py с командами stats, snapshots, scud, absences, anomalies, rules, prompts, tools, sessions, dump, snapshot del.
### **Фаза 7: Web API, Generative UI, управление памятью LLM и Topic Drift Guard**
> * **FastAPI & Безопасность:** Развернут Web API с JWT-авторизацией (bcrypt), изолированными задачами пользователей (tasks с привязкой к user\_id), гостевым режимом и ролевой моделью.
> * **Проблема деградации контекста LLM:** Вывод объемных списков задач, текстов системных промптов и таблиц снапшотов засорял контекстное окно чата, приводя к галлюцинациям и «залипанию» модели на старых правилах.
> * **Решение проблемы памяти (Topic Drift & Ephemeral Context):**
* В таблицу chat\_messages добавлен флаг is\_ephemeral \= 1 для временных сообщений.
* Введены двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик отвлечений idle\_turns.
* **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages().
* **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю», «сохранить») и выхода («нет, спасибо», «закончить настройку») без обращения к LLM (ответ за 0.05 сек).
* **Декларативный реестр действий (tool\_action\_registry):** Хранение шаблонов ответов, типов кнопок и признаков эфемерности в SQLite.
> * **Generative UI (Интерактивные карточки задач):** Инструмент db\_get\_tasks возвращает полезную нагрузку TASK\_INTERACTIVE\_CARD. Фронтенд рендерит живой виджет с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API без вызова LLM.
## ---
**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)**
| Таблица | Назначение и бизнес-логика | Полная структура колонок |
| :---- | :---- | :---- |
| **scud\_logs** | Хранение логов проходов СКУД, расчетного времени и снапшотов. | id (PK), log\_date, fio, fio\_clean, department, position, time\_in, first\_activity, time\_out, time\_in\_building, is\_present, anomaly\_flag, snapshot\_time, snapshot\_id, created\_at |
| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id (PK), snapshot\_date, fio, fio\_clean, department, position, created\_at |
| **zup\_absences** | Официальные кадровые отклонения 1С (отпуска, больничные, командировки, декреты). | id (PK), absence\_date, fio, fio\_clean, absence\_type, created\_at |
| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id (PK), anomaly\_date, fio, anomaly\_type, details, human\_status, created\_at |
| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id (PK), rule\_text, added\_by, created\_at |
| **system\_prompts** | Версионные системные промпты агента (активный промпт по is\_active=1). | id (PK), name, prompt\_text, is\_active, updated\_at |
| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id (PK), tool\_name, category, bypass\_llm, success\_template, follow\_up\_question, action\_type, buttons\_json, is\_active, is\_ephemeral, updated\_at |
| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id (PK), state\_type, pending\_data, updated\_at |
| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id (PK), session\_id, role, content, is\_ephemeral, created\_at |
| **tasks** | Реестр задач пользователей с привязкой к user\_id. | id (PK), task\_id, module, title, priority, status, due\_date, user\_id, created\_at |
| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id (PK), username, password\_hash, is\_admin, full\_name, created\_at |
| **department\_synonyms** | База знаний соответствия аббревиатур СКУД полным названиям отделов 1С. | id (PK), short\_name, full\_name, created\_at |
| **system\_reference** | Справочник команд и примеров промптов для операторов. | id (PK), category, title, example\_prompt, description |
## ---
**4\. Актуальная архитектура каталогов и назначение файлов**
`scud_ai/`
`├── config.py # Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL`
`├── exceptions.json # Декларативные фильтры исключений (ОВК, уборщики, подрядчики)`
`├── main_etl.py # Оркестратор ETL: экспорт СКУД/1С -> SQLite -> Excel/MD отчеты`
`├── project_code_snapshot.md # Полный исходный код всех файлов репозитория`
`├── core/`
`│ └── database.py # Драйвер SQLite (WAL), функции сохранения/загрузки срезов`
`├── services/`
`│ ├── scud_export.py # Прямой SQL-экспорт из MS SQL «Орион Pro»`
`│ ├── zup_extractor.py # Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1`
`│ ├── data_loader.py # Загрузка данных, нормализация ФИО, расчет первой активности`
`│ ├── data_validator.py # Проверка свежести и наличия файлов за Вчера и Сегодня`
`│ ├── excel_exporter.py # Генерация сводки и детального отчета в Excel (openpyxl)`
`│ ├── text_reporter.py # Построение итогового Markdown-отчета через Qwen 2.5`
`│ ├── ai_verifier.py # Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД`
`│ ├── feedback_loop.py # Интерактивный консольный модуль Human-in-the-Loop`
`│ ├── knowledge_base.py # Интерфейс работы с базой знаний правил`
`│ └── share_copier.py # Резервное скачивание файлов 1С с шары /mnt/scud_share`
`├── scripts/`
`│ ├── db_cli.py # CLI-утилита управления БД, снапшотами, дампами и сессиями`
`│ ├── fix_snapshots.py # Скрипт переиндексации и нормализации снапшотов`
`│ ├── init_tool_registry.py # Инициализация декларативного реестра инструментов`
`│ └── diagnostics/`
`│ ├── inspect_db.py # Диагностика структуры таблиц и количества записей`
`│ ├── inspect_ephemeral.py # Анализ распределения постоянных и эфемерных сообщений`
`│ ├── inspect_files.py # Аудит файлов и размеров на диске`
`│ └── show_tree.py # Вывод дерева каталогов`
`├── modules/web_api/`
`│ ├── main.py # Точка входа FastAPI приложения, роутеры и раздача статики`
`│ ├── routers/`
`│ │ ├── admin.py # Управление пользователями`
`│ │ ├── auth.py # JWT аутентификация и смена паролей`
`│ │ ├── chat.py # Эндпоинты диалогов (авторизованный и гостевой)`
`│ │ └── tasks.py # REST API управления задачами`
`│ ├── llm/`
`│ │ ├── agent.py # Координатор диалога, Tool Calls и Topic Drift Guard`
`│ │ ├── schemas.py # Описание JSON-схем инструментов для Ollama`
`│ │ ├── file_parser.py # Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)`
`│ │ ├── db_tools.py # Фасадные вызовы функций БД для агента`
`│ │ ├── core/`
`│ │ │ ├── calendar_utils.py # Формирование системного контекста дат и дней недели`
`│ │ │ ├── fast_path.py # Быстрый перехват команд подтверждения/отмены (0.05 с)`
`│ │ │ ├── ollama_client.py # HTTP-клиент взаимодействия с Ollama API`
`│ │ │ ├── prompt_merger.py # Парсинг и слияние правок системного промпта`
`│ │ │ └── tool_injector.py # Очистка сырых тегов и артефактов Tool Calls`
`│ │ └── db/`
`│ │ ├── connection.py # Подключение к scud_orion_ai.db`
`│ │ ├── db_chat.py # Сохранение истории и очистка эфемерных сообщений`
`│ │ ├── db_prompts.py # Системные промпты, стейты и реестр действий`
`│ │ ├── db_snapshots.py # Выборка и удаление снапшотов`
`│ │ └── db_tasks.py # CRUD операции с задачами пользователей`
`│ └── static/`
`│ ├── index.html # SPA разметка (модалки, чат, боковая панель задач)`
`│ ├── css/styles.css # Стили и мобильные оптимизации`
`│ └── js/`
`│ ├── app.js # Инициализация приложения`
`│ ├── auth.js # Логика входа, смены паролей и администрирования`
`│ ├── chat.js # Обработка сообщений, Drag&Drop, виджет задач и кнопки`
`│ └── tasks.js # Управление боковой панелью задач (Drawer)`
`└── docs/`
`└── development_roadmap.md # Стратегическая дорожная карта и архитектурный бэклог`
## ---
**5\. Справочник команд командной строки (CLI Reference)**
### **Главный конвейер контроллинга (main\_etl.py)**
> * python main\_etl.py — стандартный дневной запуск: экспорт свежих данных СКУД и 1С, сохранение снапшота в SQLite, генерация Excel и Markdown отчетов.
> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL.
> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота.
> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG).
### **CLI-утилита управления SQLite базой данных (scripts/db\_cli.py)**
> * python scripts/db\_cli.py stats — общая статистика количества записей по всем таблицам БД.
> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (самые свежие сверху).
> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — просмотр логов СКУД и опциональный экспорт в Excel.
> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП.
> * python scripts/db\_cli.py anomalies — история выявленных аномалий.
> * python scripts/db\_cli.py rules — правила базы знаний компании.
> * python scripts/db\_cli.py prompts — просмотр системных промптов из таблицы system\_prompts.
> * python scripts/db\_cli.py tools — вывод декларативного реестра действий инструментов и шаблонов кнопок.
> * python scripts/db\_cli.py sessions — просмотр активных сессионных стейтов и висящих черновиков.
> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID.
> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за выбранный день.
> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel-файл.
## ---
**6\. Стратегическая дорожная карта развития (Roadmap & Backlog)**
> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):**
* **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP.
* **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений.
* **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py.
> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):**
* Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData.
* Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут).
* Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку.
> 3. **Интерактивный Task Planner & Roadmap Engine:**
* Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks.
> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):**
* Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст.
> 5. **Автоматизация расписания срезов и Telegram-алерты:**
* Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time.
* Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram.
+248
View File
@@ -0,0 +1,248 @@
# План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов.
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`).
---
## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]`
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`.
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию[cite: 2]:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 2].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 2].
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 2].
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 2].
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 2].
---
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]`
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 2].
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 2].
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 2].
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 2].
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 2].
---
## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]`
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 2].
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 2].
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 2].
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 2].
---
## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]`
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 2].
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 2].
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 2].
- [x] **Строгий вызов Базы Знаний:** внедрено правило 2.9 в системный промпт для пресечения текстовой имитации вызова `db_get_rules`[cite: 2].
---
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]`
- [x] **Масштабирование UI задач:**
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 2].
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 2].
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 2].
- [x] **Инлайн-редактирование карточки задачи:**
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 2].
- Отображение даты создания задачи[cite: 2].
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 2].
- [x] **Детерминированный Fast-Path и двухфазное удаление:**
- Мгновенная смена статусов без задержек LLM[cite: 2].
- Карточка подтверждения удаления с автоочисткой контекста[cite: 2].
- [x] **Генерация отчетов задач в Markdown:**
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 2].
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 2].
- [x] **Доменная консолидация задач:**
- Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 2].
---
## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]`
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00 / 23:59:59[cite: 2].
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 2].
- [x] **Интерактивный UI с чекбоксами и защитой срезов:**
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 2].
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 2].
- Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 2].
- [x] **Декларативное управление удалением срезов:**
- Добавлен параметр `confirmed` в схему `TOOLS_SCHEMA` для `db_delete_snapshots`, исключающий зацикливание подтверждений в LLM.
- Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 2].
- Корректное отображение времени создания срезов в интерфейсе (устранение смещения UTC относительно локального времени сервера).
---
## 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
- [x] **Выделение общего слоя ядра (`core/`):**
- Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов[cite: 2].
- DDL-схемы таблиц и индексов (`core/schema.py`)[cite: 2].
- Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`)[cite: 2].
- Фасад обратной совместимости (`core/database.py`)[cite: 2].
- [x] **Декомпозиция предметных доменов (`services/`):**
- Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`)[cite: 2].
- [x] **Рефакторинг ETL-конвейера (`services/scud_etl/`):**
- Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`[cite: 2].
- [x] **Модульный ИИ-оркестратор (`modules/ai_engine/`):**
- Динамический строитель системного контекста (`context_builder.py`)[cite: 2].
- Изолированные обработчики инструментов в `handlers/`[cite: 2].
---
## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и кадровый аудит `[ЗАВЕРШЕНО]`
- [x] **Реестр исключений в SQLite (`exceptions_registry`):**
- Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД[cite: 2].
- Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`[cite: 2].
- [x] **Агрегация мульти-пропусков физлиц в СКУД:**
- Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие)[cite: 2].
- Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`)[cite: 2].
- [x] **Умный выбор ставки совместителей 1С:ЗУП:**
- Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД[cite: 2].
- [x] **Отказ от искусственного автозакрытия смен:**
- Полное отключение механизма дорисовывания 8.5 часов (`calculate_autoclose_time`) по согласованию с отделом кадров.
- Сохранение честного статуса «Нет выхода» для прозрачности кадрового аудита и выявления нарушений.
- [x] **Кэш сопоставлений личностей (`person_identity_mapping`):**
- Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`[cite: 2].
- Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`[cite: 2].
- [x] **Каскадный fallback кадровых отсутствий:**
- `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`[cite: 2].
---
## 10. Разделение генераторов и почасовые срезы `[ЗАВЕРШЕНО]`
- [x] Разделение логики генерации на `svodka_generator.py` и `otchet_generator.py`[cite: 2].
- [x] Перевод времени суточного среза `Y` на `23:59:59`[cite: 2].
- [x] Интеллектуальный поиск срезов `services/snapshots/finder.py` (Snap-to-Grid ±20 мин)[cite: 2].
- [x] Флаг `--time` и интерактивный help в `main_etl.py`[cite: 2].
- [x] Флаг `--export-only` для почасового крона[cite: 2].
- [x] Политика ночной ротации промежуточных срезов `services/snapshots/retention.py`[cite: 2].
- [x] Исправление целочисленного инкремента `snapshot_id`[cite: 2].
---
## 11. Стабилизация генератора отчетов и верстки `[ЗАВЕРШЕНО]`
- [x] **Миграция на `XlsxWriter`:** полный перевод экспорта книг (`scud_export.py`, `excel_exporter.py`) на чистый генератор, устранивший ошибки OpenXML и окна восстановления при открытии[cite: 2].
- [x] **Интективные группировки в Сводке:** восстановление сворачивания категорий по умолчанию (`level=1`, `collapsed=True`) и кнопок управления уровнями `[+]`/`[-]` (`outline_settings(symbols_below=False)`)[cite: 2].
- [x] **Компактная экранная верстка Детального отчета:** центрирование столбца «Подразделение», расширение столбца «ФИО» (+10%, ширина 33), оптимизация ширины колонок «Первая активность» (11) и «Отклонение от нормы» (11) для отображения без горизонтальной прокрутки[cite: 2].
- [x] **Защита от блокировок занятых файлов (Fallback Timestamp):** механизм перехвата `FileCreateError`/`OSError` в `safe_close_workbook` с сохранением копии при открытом в Excel файле[cite: 2].
- [x] **Безопасная синхронизация 1С с сетевой шары:** переход на `shutil.copyfile` в `share_copier.py` для устранения сбоев прав доступа (`Operation not permitted`) на SMB/CIFS-ресурсах[cite: 2].
---
## 12. UI/UX веб-интерфейса и управление реестрами `[ЗАВЕРШЕНО]`
- [x] **Паттерн Gemini-скролла:** реализация функции `scrollToUserMessageTop` в `core.js`, позиционирующей свежий вопрос пользователя строго по верхней кромке видимой области[cite: 2].
- [x] **Полное скрытие истории:** предыдущие объемные ответы модели гарантированно вытесняются за верхний край экрана[cite: 2].
- [x] **Стабилизация панели ввода:** настройка `padding-bottom: 80vh` для `#chat-messages-container` в `index.html`, обеспечивающая необходимый запас высоты прокрутки без сдвига строки ввода текста[cite: 2].
- [x] **Модальные формы реестров с автокомплитом 1С:**
- Полный отказ от браузерного `prompt()` при работе со списками исключений.
- Единое Tailwind-окно для добавления исключений с живым поиском сотрудников по базе 1С:ЗУП (`staff-autocomplete`) и полем для комментария/основания.
---
## 13. Архитектура обработки событий турникетов и двухконтурный учет `[ЗАВЕРШЕНО]`
- [x] **Опора на физический факт прохода (Event 32):**
- Устранение потери событий из-за рассинхронизации меток Event 28 (разрешение) и Event 32 (проход).
- Переход на плоский SQL-запрос выборки первого входа (`Mode = 1`) и последнего выхода (`Mode = 2`).
- [x] **Двухконтурная модель турникетов (Левый/Правый PERCo):**
- Разрешение использования обоих турникетов (`DoorIndex IN (1, 2)`) для сотрудников дворовых служб (отделы `ЭТО`, `ЛЦ` и др.).
- Защита основного пула офисных сотрудников от транзитных отметок на правом турникете (`DoorIndex = 1`).
- Реестр «Пр. турникет» (`turnstile_fio`, `turnstile_departments`) в базе данных и веб-панели.
- Корректное сопоставление составных ФИО (`Name + FirstName + MidName`) в запросе к `pList`.
- [x] **Таблица сырых событий (`scud_events_raw`):**
- Создание DDL-схемы таблицы и логирование всех физических проходов дня параллельно со снапшотами.
---
## 14. Миграция инфраструктуры, отказоустойчивость отчетов и Упрощенный отчет `[ЗАВЕРШЕНО]`
- [x] **Миграция на новый сервер (Debian 12):**
- Актуализация путей виртуального окружения в cron-скриптах (`scripts/cron/*.sh`).
- Восстановление прямого подключения к базе 1С:ЗУП (MS SQL Server `ACCOUNT-01`).
- Настройка автоматического CIFS-монтирования шары отчетов `//storage/SCUD/Отчеты` в `/etc/fstab` с правами пользователя.
- [x] **Разделение статусов наличия в кадровых реестрах (`merger.py`):**
- Выделение сотрудников, заведенных в СКУД, но не принятых в ЗУП (`not_hired_yet = True`), в отдельный раздел сводки **«Не приняты на работу»**.
- Полное исключение непринятых сотрудников из детального суточного табеля за прошлые смены.
- Обособление штатных сотрудников без карт СКУД (`no_scud_pass = True`) в раздел **«Нет пропуска»** с исключением их из «Неизвестных».
- Устранение предупреждений `UserWarning: Boolean Series key will be reindexed` при вычислении метрик штата.
- [x] **Стабилизация генерации Excel-книг (`otchet_builder.py`):**
- Включение защитной опции `nan_inf_to_errors: True` в `xlsxwriter.Workbook`.
- Санитизация ячеек от значений `NaN` / `None` / `pd.NA` перед записью, устранившая критический сбой `TypeError: NAN/INF not supported in write_number()`.
- [x] **Генератор «Упрощенного отчета» (`services/reports/simplified_builder.py`):**
- Создание генератора по эталонному шаблону: заголовок `Упрощенный отчет: c ДД.ММ.ГГГГ по ДД.ММ.ГГГГ`, сокращение ФИО до инициалов (`Фамилия И.О.`), отображение времени с текстовыми заглушками (`Нет входа (0:00)`, `Нет выхода (23:59)`).
- Сортировка по подразделениям и алфавиту сотрудников.
- Интеграция этапа сборки упрощенного отчета за вчера в ETL-конвейер (`main_etl.py`).
- [x] **Компактный динамический сборщик слепков (`make_etl_snapshot.py`):**
- Автоматический обход дерева (auto-discovery) с отсечением дублирующих архивных манифестов, временных файлов и пустых `__init__.py`.
---
## 15. ПРИОРИТЕТ 1: Внутридневной контроль, экран «Кто в здании» и On-Demand генерация `[В РАБОТЕ]`
- [x] **Оперативный контроль «Кто в здании прямо сейчас» (Live Presence):**
- [x] REST API эндпоинт `GET /api/v1/presence/live` на базе таблицы `scud_events_raw` и оконной функции последнего события прохода.
- [x] Выделенная модальная панель в веб-интерфейсе:
- Метрики реального времени: *Штат 1С*, *В здании*, *Вышли*, *На удаленке*, *В командировке/отпуске*, *Не пришли*, *Исключения*.
- Быстрый поиск сотрудника и фильтрация по подразделениям.
- Учет специфики служебных входов/выходов во двор (флигель/двор).
- [x] **On-Demand генерация отчетов из UI и сайдбара:**
- [x] Кнопка **«Сводка»** в сайдбаре: моментальный сбор книги по последнему готовому срезу SQLite без блокирующих опросов MS SQL.
- [x] Кнопки **«Детальн.»** и **«Упрощ.»**: строгое формирование отчетов за **ВЧЕРА** (с учетом выходных) по финальному суточному срезу `Y` (23:59:59).
- [x] Каскадный fallback кадровых файлов: при отсутствии свежего штата за текущее утро автоматическое использование актуального среза из `zup_staff` в SQLite.
- [x] **Атомарная публикация и отдача файлов (Report Delivery Engine):**
- [x] Устранение 404 ошибок: рекурсивный поиск файлов в иерархии `output/reports/{ГОД}/{МЕСЯЦ}/` и декодирование URL-имён.
- [x] Синхронизация при отдаче: автоматическая отдача прямой ссылки на скачивание в браузер.
- [ ] **Реестр «Графики работы» (Custom Work Schedules):**
- [ ] Таблица в SQLite `work_schedules_registry` (`fio_clean`, `department`, `schedule_type`, `norm_hours`, `time_start_window`, `time_end_window`, `valid_from`, `valid_to`).
- [ ] Интеграция индивидуальной нормы часов (4, 7, 11, 24 ч) в калькулятор выработки `calculators.py` вместо константы 8 ч.
- [ ] Вкладка управления графиками в сайдбаре «Реестры» с автоподбором сотрудников из 1С.
---
## 16. ПРИОРИТЕТ 2: Кадровый арбитраж до отчетов и интеграция с MS Exchange `[В ПЛАНАХ]`
- [ ] **Почтовый агент MS Exchange (Email-Driven Controller & Human-in-the-Loop):**
- [ ] Фоновый сервис `services/mail/exchange_bot.py` для опроса служебного ящика по IMAP/SMTP (`imaplib` / `email`) или EWS (`exchangelib`).
- [ ] Белый список доверенных отправителей (адреса сотрудников ОК, СБ и руководства).
- [ ] **Генерация отчетов по письму:** распознавание запросов в теме/теле писем (*«Сводка на 14:00»*, *«Отчет за 22.09.2026»*) и отправка файла в ответном письме.
- [ ] **Разрешение кадровых коллизий через почту:** обработка ответов СБ на утреннюю рассылку аномалий (подтверждение связок в `person_identity_mapping`) и регистрация командировок/служебок в `manual_absences`.
- [ ] Двухфазный Guardrail: запрос подтверждения у кадровика по почте при низкой уверенности в сопоставлении тёзок (<90%).
- [ ] **Интеллектуальный кадровый арбитраж ДО генерации отчетов:**
- [ ] Перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`.
- [ ] **Якорный табельный номер (TabNo Matching):** извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП, защита от смены фамилий и опечаток.
- [ ] 4-уровневая система предохранителей (Точный матч $\rightarrow$ Табельный номер $\rightarrow$ Кэш связок $\rightarrow$ ИИ-арбитраж).
---
## 17. ПРИОРИТЕТ 3: Векторная база знаний (RAG) и внешний контекст `[В ПЛАНАХ]`
- [ ] **Векторный поиск по регламентам компании (ChromaDB / RAG Engine):**
- [ ] Встраиваемая векторная база для семантического поиска по ПВТР, положениям о пропускном режиме и должностным инструкциям.
- [ ] Мгновенная выдача ссылок на пункты локальных нормативных актов при консультации оператора в чате.
- [ ] Семантическая классификация нестандартных формулировок причин отсутствий из писем и заявлений.
- [ ] **Внешний контекстный инструмент (Web Search Agent):**
- [ ] Нативный инструмент `web_search` для обращения к производственному календарю РФ (переносы праздников, предпраздничные сокращенные смены по ст. 95 ТК РФ).
- [ ] Справка по статьям Трудового кодекса РФ (сверхурочные, выходные дни).
---
## 18. Исследовательский трек: Изолированная песочница кода `[ОТЛОЖЕНО]`
- [ ] **Docker/gVisor контур:**
- [ ] Изолированный контейнер без доступа к внешней сети (`network: none`) с жесткими cgroups-лимитами и монтированием данных в режиме Read-Only.
- [ ] Генерация и выполнение Python/Pandas скриптов для построения сложных графиков и нестандартной статистики на лету.
---
## 19. Доменный модуль канцелярии и документов (`services/office/`) `[В РАБОТЕ]`
- [x] **Изоляция офисного домена от кадрового ядра СКУД:**
- [x] Создание независимого пакета `services/office/` (`document_extractor.py`, `word_builder.py`, `service.py`).
- [x] Снятие ограничений контекста диалога: многостраничный постраничный обход PDF вместо обрезания первой страницы.
- [x] Генерация ГОСТ-документов Word (`.docx`) с выдачей контрастной карточки скачивания в интерфейс чата.
- [ ] **Двухфазный конвейер OCR и глубокая нормализация (Vision + LLM Refiner):**
- [ ] **Фаза 1 (Vision Extraction):** повышение разрешения рендеринга до `250–300 DPI` для чёткой отрисовки тонких линий шариковых ручек и штампов.
- [ ] **Фаза 2 (LLM Post-Processing Refiner):** вычитка текста через `qwen2.5:14b`:
- Устранение диалогового шума и случайных приветствий модели.
- Склейка разорванных строк внутри абзацев.
- Автоматическая сборка списков участников и табличных данных в полноценные таблицы Word (`Table Grid`).
- [ ] **Специализированное распознавание рукописных виз и резолюций канцелярии:**
- [ ] Распознавание наклонного и беглого почерка на полях и в шапке документов (визы «Согласовано», резолюции руководства, подписи, даты).
- [ ] Выделение рукописных пометок в стандартизированные структурные блоки: `[Резолюция: ...]` в начале страницы.
- [ ] **Two-Pass Context Injection:** перекрёстное сопоставление неразборчивых фамилий и инициалов с реестром участников совещания/штатом из тела самого документа.
+7053
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -9,7 +9,6 @@
"fio": [
"Таткало Валерий Валерьевич",
"Петренюк Андрей Германович",
"Чуркина Елена Геннадьевна",
"Михалев Сергей Геннадьевич"
],
"position_keywords": [
@@ -18,5 +17,9 @@
"дворник",
"гардероб",
"рабочий по обслуживанию"
],
"include_fio": [
"Тарасенко Александр Александрович",
"Журиков Михаил Николаевич"
]
}
+161 -515
View File
@@ -1,571 +1,217 @@
"""
===============================================================================
FILE: main_etl.py
PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
===============================================================================
"""
import os
import sys
import json
import argparse
import logging
import pandas as pd
from datetime import datetime, timedelta
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.scud_etl.svodka_generator import generate_svodka_service
from services.scud_etl.otchet_generator import generate_otchet_service
from services.text_reporter import generate_markdown_report
from services.snapshots.retention import cleanup_old_intermediate_snapshots
from services.snapshots.retention import purge_day_intermediate_snapshots
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio
from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share
from services.data_validator import check_file_freshness
from services.data_loader import load_1c_data_smart, load_scud_data
from services.excel_exporter import generate_summary_excel, generate_detailed_excel
from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai
from services.text_reporter import generate_markdown_report
from services.feedback_loop import review_ai_decisions
from services.knowledge_base import load_knowledge_base
from core.database import (
init_db,
save_scud_to_db,
save_staff_to_db,
save_absences_to_db,
save_anomalies_to_db,
load_scud_from_db_by_snapshot,
load_staff_from_db,
load_absences_from_db,
get_latest_snapshot_time,
has_scud_logs_for_date
)
from services.excel_exporter import export_raw_scud
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
def load_exceptions_config():
"""Загружает файл exceptions.json из корня проекта."""
root_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(root_dir, "exceptions.json")
if not os.path.exists(json_path):
return {}
try:
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[⚠️] Ошибка чтения exceptions.json: {e}")
return {}
def print_help():
print("""
===============================================================================
🛠️ SCUD ORION AI — СИСТЕМА КОНТРОЛЛИНГА И СВОДНЫХ ОТЧЕТОВ
===============================================================================
Использование:
python main_etl.py [ОПЦИИ]
Доступные аргументы:
-h, --help, help Показать эту справку и выйти
--date ДД.ММ.ГГГГ Дата расчета (по умолчанию: текущий рабочий день)
--time ЧЧ:ММ Время среза для сводки (например: 14:30)
Ищет ближайший срез (±20 мин) или запрашивает On-Demand экспорт
--snapshot ID Точный ID снапшота для расчета (например: 20260827-002)
--skip-export Пропустить выгрузку СКУД из MS SQL (работать только с SQLite)
--export-only ТОЛЬКО сделать экспорт/снапшот СКУД в БД без построения отчетов
-d, --debug Включить режим расширенной отладки
def apply_exceptions_from_json(df, exceptions_cfg):
"""
Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json.
Выполняется мгновенно без цикличных HTTP-запросов к ИИ.
"""
if df is None or df.empty or not exceptions_cfg:
if df is not None:
df['is_excluded'] = False
return df
Примеры использования:
python main_etl.py
👉 Полный суточный цикл: экспорт -> отчет за вчера -> сводка за сегодня -> ИИ.
deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d]
exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
python main_etl.py --export-only
👉 Почасовой тихий срез в БД (для cron) без генерации отчетов.
df['is_excluded'] = False
python main_etl.py --date 27.08.2026 --time 12:15 --skip-export
👉 Построить сводку за 27.08 на 12:15 без запроса к внешнему MS SQL.
for idx, row in df.iterrows():
fio_clean = row.get('fio_clean', '')
dep_1c = str(row.get('Подразделение', '')).lower()
dep_scud = str(row.get('department_scud', row.get('department', ''))).lower()
pos = str(row.get('Должность', '')).lower()
is_fio_exc = fio_clean in exc_fios
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False
# Быстрая проверка отделов по подстрокам
is_dep_exc = False
if deps:
is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps)
if is_fio_exc or is_dep_exc or is_pos_exc:
df.at[idx, 'is_excluded'] = True
return df
def filter_report_dataframe(merged_df):
"""
Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета,
ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП.
"""
if merged_df is None or merged_df.empty:
return merged_df
has_1c_reason = (
merged_df['Вид_отсутствия'].notna() &
(merged_df['Вид_отсутствия'].astype(str).str.strip() != '') &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
)
is_not_excluded = merged_df.get('is_excluded', False) == False
is_not_no_pass = merged_df.get('no_scud_pass', False) == False
filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy()
return filtered_df
def load_static_reason_workers():
"""Загружает реестр удалёнщиков и статических причин из CSV."""
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if not os.path.exists(static_path):
return {}
try:
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
return dict(zip(df_static['fio_clean'], df_static['reason']))
except Exception as e:
print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}")
return {}
def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None):
"""
Автоматически выявляет истинные аномалии СКУД ⟷ 1С.
"""
anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк']
for idx, row in merged_df.iterrows():
fio = row.get('Сотрудник', row.get('fio_clean', ''))
fio_clean = row.get('fio_clean', '')
is_present = row.get('Пришел', False)
is_exc = row.get('is_excluded', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
anom_flag = row.get('anomaly_flag', 'NONE')
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
if is_present and has_1c_reason:
reason_lower = reason_1c.lower()
is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS)
if not is_allowed_trip and not is_fio_whitelisted_in_kb:
anomalies.append({
"type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
"fio": fio,
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
})
if is_exc and not has_1c_reason:
continue
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
anomalies.append({
"type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
"fio": fio,
"details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
})
if scud_fios_set is not None:
if fio_clean not in scud_fios_set and not has_1c_reason:
anomalies.append({
"type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
"fio": fio,
"details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)"
})
return anomalies
def aggregate_scud_by_employee(df, debug=False):
"""Агрегирует проходы СКУД по уникальным сотрудникам."""
if df is None or df.empty or 'fio_clean' not in df.columns:
return df
aggregated = []
for fio_clean, group in df.groupby('fio_clean', sort=False):
is_present = group['Пришел'].any() if 'Пришел' in group.columns else False
if is_present and 'Пришел' in group.columns:
present_rows = group[group['Пришел'] == True]
best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict()
else:
best_row = group.iloc[0].to_dict()
best_row['Пришел'] = is_present
aggregated.append(best_row)
return pd.DataFrame(aggregated)
python main_etl.py --snapshot 20260827-001 --skip-export
👉 Расчет отчетов строго по выбранному снапшоту из SQLite.
===============================================================================
""")
def main():
help_text = """
Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2)
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
print_help()
sys.exit(0)
ПРИМЕРЫ ЗАПУСКА:
python main.py -- Обычный дневной запуск
python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite
python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота
python main.py -d -- Запуск в режиме расширенной отладки (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов")
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту")
parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания")
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("-h", "--help", action="store_true")
parser.add_argument("-d", "--debug", action="store_true")
parser.add_argument("--skip-export", action="store_true")
parser.add_argument("--export-only", action="store_true")
parser.add_argument("--date", type=str, default=None)
parser.add_argument("--time", type=str, default=None)
parser.add_argument("--snapshot", type=str, default=None)
args = parser.parse_args()
DEBUG = args.debug
if args.help:
print_help()
sys.exit(0)
print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}")
print("=" * 60)
init_db()
kb_data = load_knowledge_base()
kb_rules = kb_data.get("rules", [])
exceptions_cfg = load_exceptions_config()
if args.snapshot:
snapshot_param = args.snapshot
print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'")
elif args.skip_export:
snapshot_param = get_latest_snapshot_time()
print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')")
else:
snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
# Автоматическая ротация архивных почасовых срезов
if not args.skip_export and not args.snapshot:
try:
run_export(save_xlsx=True, debug=DEBUG)
except Exception as e:
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
else:
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
deleted_count = cleanup_old_intermediate_snapshots(days_to_keep_all=2)
if deleted_count > 0:
print(f"[🧹] Ротация БД: очищено {deleted_count} строк промежуточных архивных срезов.")
# ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА
now = datetime.now()
if args.date:
today_str = args.date.replace('_', '.')
dt_target = datetime.strptime(today_str, "%d.%m.%Y")
days_back = 3 if dt_target.weekday() == 0 else 1
yesterday_str = (dt_target - timedelta(days=days_back)).strftime("%d.%m.%Y")
else:
today_str = now.strftime("%d.%m.%Y")
if now.weekday() == 0:
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
else:
yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
# [Этап 0] Выгрузка свежих данных СКУД
if not args.skip_export and not args.snapshot:
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
run_export(input_date=args.date, debug=args.debug, save_xlsx=True)
else:
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
# Если запрошен режим тихого почасового среза — выходим без тяжелых генераций
if args.export_only:
print(f"\n[✓] Режим --export-only: срез зафиксирован в SQLite. Генерация отчетов пропущена.")
sys.exit(0)
# [Этап 0.5] Синхронизация файлов с шары 1С
if not args.snapshot and not args.skip_export:
print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
copy_1c_files_from_share()
print("[1/5] Проверка актуальности и свежести входных данных...")
is_valid, warnings, errors, has_today_1c = check_file_freshness()
if warnings:
print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---")
for w in warnings:
print(f" • {w}")
print("-" * 45)
if not is_valid:
print("\n" + "!" * 60)
print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!")
for e in errors:
print(f" {e}")
print("!" * 60)
sys.exit(1)
print("[✓] Проверка доступности данных успешно пройдена!\n")
else:
print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...")
has_today_1c = True
print("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...")
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
# [Этап 1-2] Загрузка данных
print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...")
df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True)
df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False)
# 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ
if args.snapshot or args.skip_export:
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param)
df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns:
target_date_str = str(raw_scud_today_df['log_date'].iloc[0])
else:
target_date_str = DATE_TODAY
if df_scud_today is not None and not df_scud_today.empty:
export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx")
dt_target = datetime.strptime(target_date_str, "%d.%m.%Y")
if dt_target.weekday() == 0: # Понедельник -> Пятница
dt_yesterday = dt_target - timedelta(days=3)
else:
dt_yesterday = dt_target - timedelta(days=1)
yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y")
# [Этап 3] Детальный и Упрощенный отчеты за вчера
print(f"\n[3/5] Обработка и построение детального и упрощенного отчетов за ВЧЕРА ({yesterday_str})...")
res_otchet = generate_otchet_service(target_date=yesterday_str)
if res_otchet.get("status") == "success":
print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}")
print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}")
# Генерация Упрощенного отчета за вчера
try:
from services.reports.simplified_builder import generate_simplified_excel
df_scud_y = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True)
df_staff_y, df_abs_y = load_1c_files_for_date(yesterday_str)
df_merged_y = merge_scud_and_1c(df_scud_y, df_staff_y, df_abs_y)
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
simplified_path = generate_simplified_excel(df_merged_y, date_str=yesterday_str)
print(f"[✓] Упрощенный отчет за {yesterday_str} успешно сформирован: {simplified_path}")
except Exception as e:
print(f"[⚠️] Ошибка формирования упрощенного отчета: {e}")
# Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк)
df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True)
else:
target_date_str = DATE_TODAY
yesterday_date_str = DATE_YESTERDAY
raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param)
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False)
if df_staff_yesterday is not None:
save_staff_to_db(df_staff_yesterday, yesterday_date_str)
if df_absent_yesterday is not None:
save_absences_to_db(df_absent_yesterday, yesterday_date_str)
if df_staff_today is not None:
save_staff_to_db(df_staff_today, target_date_str)
if df_absent_today is not None:
save_absences_to_db(df_absent_today, target_date_str)
staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else []
static_reasons_dict = load_static_reason_workers()
# ============================================================
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ)
# ============================================================
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False
scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else []
fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
if fio_mapping_scud_y:
raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
# [Этап 4] Сводка за сегодня через svodka_generator
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...")
res_svodka = generate_svodka_service(
target_date=today_str,
target_time=args.time,
snapshot_id=args.snapshot
)
if res_svodka.get("status") == "success":
print(f"[✓] {res_svodka.get('message')}: {res_svodka.get('filepath')}")
if res_svodka.get("note"):
print(f" ℹ️ {res_svodka.get('note')}")
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
# Если запуск ночной (после 22:00) или закрывающий смену — зачищаем дневные срезы
if now.hour >= 22 and not args.skip_export:
purged = purge_day_intermediate_snapshots(today_str)
if purged > 0:
print(f"[🧹] Ночная очистка: удалено {purged} строк дневных срезов за {today_str}. Итоговый Y-срез сохранен.")
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
# ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ
merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame())
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today)
if not merged_yesterday.empty:
if not raw_scud_yesterday_df.empty:
if 'Подразделение' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
elif 'department' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
else:
raw_scud_yesterday_df['department_scud'] = ''
merged_yesterday = merged_yesterday.merge(
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if 'Вид_отсутствия' in merged_yesterday.columns:
merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия'])
# Присоединяем отсутствия СТРОГО за дату yesterday_date_str
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
merged_yesterday = merged_yesterday.merge(
df_absent_yesterday[['fio_clean', 'Вид_отсутствия']],
on='fio_clean',
how='left'
)
if 'Пришел' not in merged_yesterday.columns:
merged_yesterday['Пришел'] = False
else:
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
if 'Сотрудник' not in merged_yesterday.columns:
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
mask_exc_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == True)
)
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set()
merged_yesterday['no_scud_pass'] = (
(~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == False)
)
anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set)
save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str)
filtered_yesterday = filter_report_dataframe(merged_yesterday)
generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str)
yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y")
yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00")
save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True)
print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}")
# ============================================================
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА)
# ============================================================
if not args.snapshot and not args.skip_export:
save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False)
if has_today_1c and df_staff_today is not None and df_absent_today is not None:
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...")
staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns:
raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False
scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else []
fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean)
if fio_mapping_scud:
raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply(
lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x
)
absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist()
if absent_unrecognized_today:
fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean)
if fio_mapping_absent:
df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply(
lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x
)
raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG)
merged_today = df_staff_today.copy()
if not raw_scud_today_df.empty:
if 'Подразделение' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение']
elif 'department' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['department']
else:
raw_scud_today_df['department_scud'] = ''
merged_today = merged_today.merge(
raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if df_absent_today is not None and not df_absent_today.empty:
merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
if 'Пришел' not in merged_today.columns:
merged_today['Пришел'] = False
else:
merged_today['Пришел'] = merged_today['Пришел'].fillna(False)
if 'Сотрудник' not in merged_today.columns:
merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean'])
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today['fio_clean'] == fio_clean)
)
merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val
merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg)
mask_exc_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == True)
)
merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
merged_today['no_scud_pass'] = (
(~merged_today['fio_clean'].isin(scud_fios_today_set)) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == False)
)
scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set)
mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df)
if mass_failure_today and mass_failure_today.get("is_mass_failure"):
print("\n" + "!" * 60)
print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)")
print(mass_failure_today["alert_text"])
print("!" * 60 + "\n")
save_anomalies_to_db(anomalies_list, target_date_str)
is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False
is_exc_today = merged_today.get('is_excluded', False) == True
absent_explained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
absent_explained = df_merged_today[
(df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].notna()) &
(~df_merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_unexplained = df_merged_today[
(df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'None']))) &
(df_merged_today.get('is_excluded', False) == False) &
(df_merged_today.get('not_hired_yet', False) == False) &
(df_merged_today.get('no_scud_pass', False) == False)
]
absent_unexplained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass_today) &
(~is_exc_today)
]
scud_present_but_absent_in_1c = merged_today[
(~is_exc_today) &
(merged_today['Пришел'] == True) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
filtered_anomalies_list = [
a for a in anomalies_list
if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
]
report_text = generate_markdown_report(
merged_df=merged_today,
summary_md = generate_markdown_report(
merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False],
absent_explained=absent_explained,
absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
anomalies_list=filtered_anomalies_list,
raw_scud_df=raw_scud_today_df,
raw_absent_df=df_absent_today,
date_str=target_date_str
scud_present_but_absent_in_1c=pd.DataFrame(),
anomalies_list=anomalies_today,
raw_scud_df=df_scud_today,
raw_staff_df=df_staff_today,
raw_absent_df=df_abs_today,
date_str=today_str
)
generate_summary_excel(merged_df=merged_today, date_str=target_date_str)
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md")
with open(md_report_path, "w", encoding="utf-8") as f:
f.write(report_text)
print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}")
suspicious_cases = []
for a in anomalies_list:
if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''):
suspicious_cases.append({
'fio_target': a['fio'],
'reason': f"{a['type']}: {a['details']}"
})
if suspicious_cases:
review_ai_decisions(report_text, suspicious_cases)
os.makedirs("output", exist_ok=True)
md_file_path = f"output/Сводка_контроллинга_{today_str}.md"
with open(md_file_path, "w", encoding="utf-8") as f:
f.write(summary_md)
print(f"[✓] Текстовый отчет сохранен в: {md_file_path}")
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
print(report_text)
else:
print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
print(summary_md)
if __name__ == "__main__":
+56
View File
@@ -0,0 +1,56 @@
# 🗺️ Дорожная карта задач проекта (ROADMAP)
> **Сформировано:** 2026-08-19 18:40 | **Всего задач:** 16
---
## Модуль `api`
- [x] **#35** [HIGH] `[ЗАВЕРШЕНО]` Создание REST API на FastAPI (/api/v1/snapshots, /api/v1/scud/logs, /api/v1/anomalies)
---
## Модуль `core/database`
- [x] **#22** [HIGH] `[ЗАВЕРШЕНО]` Перевод подключения 1С:ЗУП на продакшн MS SQL сервер ACCOUNT-01 + гибридный фоллбэк
- [x] **#37** [MEDIUM] `[ЗАВЕРШЕНО]` Создание модуля персистентной памяти проекта в SQLite и CLI-инструмента для фиксации архитектурных решений
---
## Модуль `general`
- [x] **#38** [HIGH] `[ЗАВЕРШЕНО]` По запросу, вывести возможности системы, которые уже настроены. Например на запрос о возможностях по СКУД. И изменить стартовую плашку.
- [ ] **#41** [MEDIUM] Полностью отказаться от tool_injector.py и перевести все вызовы на нативный Function Calling языковой модели. LLM будет основным арбитром *(срок: 2026-08-21)*
- [ ] **#44** [LOW] Тестовая-3 *(срок: 2026-08-20)*
---
## Модуль `scripts`
- [x] **#26** [MEDIUM] `[ЗАВЕРШЕНО]` Создание и выполнение скрипта сквозной переиндексации снапшотов с префиксом Y
- [x] **#27** [MEDIUM] `[ЗАВЕРШЕНО]` Реализация CLI-инспектора БД и выравнивание номеров срезов для идеальной верстки в терминале
- [ ] **#33** [MEDIUM] Автоматизация сбора срезов по расписанию (Cron / Systemd Timers на 12:00, 17:00, 19:00)
---
## Модуль `services/ai_verifier`
- [x] **#28** [HIGH] `[ЗАВЕРШЕНО]` Двойная проверка исключений по department_scud и 1С, удержание 100% приоритета кадровых документов
---
## Модуль `services/scud_export`
- [x] **#23** [HIGH] `[ЗАВЕРШЕНО]` Инцидент Равина В.Э.: Фиксация 100% приоритета сырых логов pLogData СКУД над расчетом УРВ Ориона
- [x] **#25** [HIGH] `[ЗАВЕРШЕНО]` Исправление бага дублирования номеров снапшотов (переход на вычисление MAX() по суффиксу)
- [x] **#29** [LOW] `[ЗАВЕРШЕНО]` Лаконичный вывод неразмеченных сотрудников с короткими аббревиатурами отделов СКУД (РУК, ОИЗ, ПУ и др.)
- [ ] **#30** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Извлечение полных цепочек проходов pLogData (входы/выходы за смену)
- [ ] **#31** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Расчет количества и суммарной длительности отлучек/перекуров сверх обеда
---
## Модуль `services/text_reporter`
- [ ] **#32** [MEDIUM] `[В РАБОТЕ]` Внутридневной контроль: Вывод списка системных нарушителей внутреннего распорядка в Markdown-отчет
---
+300 -263
View File
@@ -2,75 +2,58 @@
===============================================================================
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent & Function Calling Dispatcher)
ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов
инструментов (Function Calling), интеграция с декларативным реестром
действий SQLite (tool_action_registry) и управление сессионными кнопками.
AI-CONTEXT-ANCHORS & INVARIANTS:
- ANCHOR[LOGGING_CONFIG]: Настройка StreamHandler в sys.stdout с propagate=False
для предотвращения дублирования строк в systemd journalctl.
- ANCHOR[DYNAMIC_CONTEXT]: Сборка системного контекста (календарь, сессия, промпт).
- ANCHOR[INFERENCE_OPTIONS]: Параметры инференса (repeat_penalty, ctx_size) для
предотвращения урезания длинных списков моделью Qwen 2.5.
- ANCHOR[TOOL_ROUTER]: Диспетчеризация функций SQLite (CRUD задач, снапшотов, KB).
- ANCHOR[PROMPT_MERGE_LOGIC]: Универсальный парсер точечного добавления и
удаления пунктов системного промпта в режиме предпросмотра (PROMPT_PREVIEW).
- ANCHOR[DATA_DRIVEN_ACTIONS]: Универсальная обработка инструментов на базе
таблицы tool_action_registry (мгновенный возврат ответов и динамических кнопок).
- ANCHOR[SECONDARY_PASS]: Вторичный вызов LLM для формирования текстового ответа
на основе полученного tool_result.
DEPENDENCIES:
- modules/web_api/llm/db_tools.py (доступ к SQLite и реестру действий)
- modules/web_api/llm/schemas.py (TOOLS_SCHEMA)
- modules/web_api/llm/core/calendar_utils.py (get_dynamic_calendar_context)
- modules/web_api/llm/core/tool_injector.py (clean_raw_tool_tags, clean_output)
MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Нативный оркестратор диалога, диспетчер Function Calling,
передача активных срезов в контекст модели и терминальные вызовы.
===============================================================================
"""
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
import os
import sys
import json
import urllib.request
import urllib.error
import logging
import re
from typing import List, Dict, Any, Tuple, Optional
# Импорт фасада базы данных
from .db.connection import get_db_connection
from .db_tools import (
db_get_active_system_prompt,
db_add_system_prompt,
db_apply_prompt_node_action,
db_get_tool_action,
db_get_tasks,
db_update_task_status,
db_update_task_details,
db_delete_task,
db_add_task,
db_tasks_edit,
db_export_tasks_markdown,
db_get_rules,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
db_get_snapshots,
db_delete_snapshots,
db_clear_session_state,
db_get_current_server_time,
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_get_stats,
db_get_anomalies,
db_get_session_states,
db_get_reference
)
from .schemas import TOOLS_SCHEMA
from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru
from .core.calendar_utils import get_dynamic_calendar_context
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept
from .core.context_manager import (
save_tool_interaction,
save_dialog_interaction,
mark_last_user_message_ephemeral,
close_tool_session_and_cleanup
)
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO)
logger.propagate = False # Исключаем дублирование сообщений в родительском логгере Uvicorn
logger.propagate = False
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
@@ -78,12 +61,7 @@ if not logger.handlers:
handler.setFormatter(formatter)
logger.addHandler(handler)
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0"
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR]
def process_chat_message(
user_id: int,
user_message: str,
@@ -93,278 +71,337 @@ def process_chat_message(
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""
Главный конвейер обработки входящего сообщения:
1. Сохранение сообщения пользователя.
2. Формирование системного контекста и вызов Ollama.
3. Выполнение вызванного Tool (если сгенерирован).
4. Опрос Data-Driven реестра действий (tool_action_registry) или вторичный проход LLM.
5. Возврат кортежа: (reply_text, chat_history, action_metadata).
Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
"""
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
# 3.1. Обогащение текста вложением (при наличии)
full_user_content = user_message
if file_context:
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"
db_history = db_get_chat_history(session_id, limit=20)
db_save_chat_message(session_id, "user", full_user_content)
# 3.2. Сборка системного контекста и правил # ANCHOR[DYNAMIC_CONTEXT]
dynamic_prompt_text = db_get_active_system_prompt()
calendar_context = get_dynamic_calendar_context()
session_state = db_get_session_state(session_id)
if not session_state:
db_purge_ephemeral_messages(session_id)
preview_status_note = ""
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение ('подтверждаю') или отмена ('отмена') пользователем]."
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
# 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
# 2. Фиксация сообщения пользователя
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_history = db_get_chat_history(session_id, limit=20)
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
system_prompt_content = (
f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n"
f"Вы общаетесь с пользователем: {user_info}.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"{calendar_context}\n\n"
f"[ПРАВИЛА И СТРОГИЕ ТРИГГЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]\n"
f"1. ТРИГГЕРЫ ПРОСМОТРА: Если запрос содержит фразы 'покажи системный промпт', 'покажи промпт', 'выведи промпт' — ТЫ ОБЯЗАН СГЕНЕРИРОВАТЬ ToolCall: db_get_system_prompt(). Категорически ЗАПРЕЩЕНО выводить текст промпта из памяти без вызова этой функции!\n"
f"2. ТРИГГЕРЫ ПРАВКИ: Если запрос содержит слова 'добавь пункт', 'удали пункт', 'измени промпт' — ТЫ ОБЯЗАН СГЕНЕРИРОВАТЬ ToolCall: db_preview_prompt_merge(prompt_text=...).\n"
f"3. ТРИГГЕРЫ ЗАДАЧ: При фразах 'покажи задачи', 'мои задачи', 'список дел' — СРАЗУ генерируй ToolCall: db_get_tasks().\n"
f"4. ЗАПРЕТ ТЕКСТА: Запрещено объяснять правила или писать названия функций текстом, если сработал триггер — просто вызывай функцию!\n\n"
f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}"
current_state_type = session_state.get("state_type") if session_state else None
state_data = session_state.get("data_json") or {} if session_state else {}
if not isinstance(state_data, dict):
state_data = {}
idle_turns = state_data.get("idle_turns", 0)
active_state_context = ""
if current_state_type == "PROMPT_PREVIEW":
active_state_context = "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n- Открыт предпросмотр изменений промпта.\n"
elif current_state_type == "SNAPSHOTS_VIEW":
active_date = state_data.get("query_date", "выбранную дату")
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
elif current_state_type == "SNAPSHOT_INSPECT":
# ⭐️ Защита от залипания: если вопрос бытовой или отвлеченный, выходим из жесткого режима инспекции
msg_l = user_message.lower().strip()
scud_terms = ["срез", "скуд", "вход", "выход", "здани", "присутств", "отсутств", "кто в", "кто сейчас", "1с", "зуп", "турникет", "карточк", "инспекци"]
if not any(t in msg_l for t in scud_terms) and len(msg_l.split()) <= 12:
db_clear_session_state(session_id)
current_state_type = None
active_state_context = ""
else:
snap_id = state_data.get("snapshot_id", "")
snap_date = state_data.get("log_date", "")
records = state_data.get("records", [])
lines = []
for r in records:
st = "Присутствовал" if r.get("is_present") else "Отсутствовал"
lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}")
dump_str = "\n".join(lines)
active_state_context = (
f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, фильтрации по входам, выходам, времени или отделам:\n"
f"1. ТЫ ОБЯЗАН ответить обычным текстом, проанализировав список ниже.\n"
f"2. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструменты (tools), такие как db_get_snapshots!\n"
f"Список сотрудников в активном срезе:\n{dump_str}\n"
)
# 3.3. Параметры инференса # ANCHOR[INFERENCE_OPTIONS]
llm_options = {
"num_predict": 8192,
"num_ctx": 8192,
"temperature": 0.1,
"repeat_penalty": 1.1,
"presence_penalty": 0.5,
"top_p": 0.9
}
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n"
f"СТРОГИЕ ПРАВИЛА:\n"
f"1. К оператору всегда обращайся на Вы.\n"
f"2. Для работы с данными системы ВСЕГДА вызывай соответствующие инструменты (tools):\n"
f" - Снапшоты и срезы СКУД -> db_get_snapshots\n"
f" - Удаление срезов -> db_delete_snapshots\n"
f" - Задачи и бэклог -> db_get_tasks, db_tasks_edit\n"
f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
f" - База знаний и регламенты -> db_get_rules\n"
f" - Аномалии СКУД/1С -> db_get_anomalies\n"
f" - Справка -> db_get_reference\n"
f"3. ЗАДАЧИ:\n"
f" - При любых запросах на просмотр задач (включая опечатки вроде 'змдачи', 'таски', 'дела', 'покажи задачи') — "
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n"
f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! "
f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n"
f"4. ПРАВИЛА КОМПАНИИ: Инструмент db_get_rules вызывай СТРОГО при вопросах о внутренних регламентах, "
"политиках или локальных правилах НАШЕЙ компании (например, 'покажи правила компании', 'какие у нас регламенты'). "
"При вопросах по Трудовому кодексу РФ (ТК РФ), законам РФ или общим юридическим нормам — отвечай подробно "
"из своих профессиональных знаний, не вызывая db_get_rules!"
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}"
)
user_msg_object = {"role": "user", "content": full_user_content}
# --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD]
try:
if image_b64:
user_msg_object["images"] = [image_b64]
messages = [
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."},
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
user_msg_object
]
payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options}
msg = call_ollama_chat(messages, is_vision=True)
else:
clean_db_history = [dict(m) for m in db_history]
for m in clean_db_history:
m.pop("images", None)
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options}
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
# --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER]
try:
req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as response:
res_data = json.loads(response.read().decode("utf-8"))
msg = res_data.get("message", {})
raw_text_reply = msg.get("content", "")
tool_calls = msg.get("tool_calls", [])
raw_text_content = msg.get("content", "")
# Фоллбэк проверка через tool_injector
tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls)
if not tool_calls:
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
if tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
messages.append(msg)
for tool in tool_calls:
tool = tool_calls[0]
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
logger.info(f"🚀 Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
tool_result_content = ""
# --- 5.1. УНИВЕРСАЛЬНАЯ ПРОВЕРКА DATA-DRIVEN РЕЕСТРА (tool_action_registry) --- # ANCHOR[DATA_DRIVEN_ACTIONS]
action_cfg = db_get_tool_action(fn_name)
if fn_name == "db_confirm_prompt_preview":
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
db_add_system_prompt("main_agent", session_state.get("pending_data", ""))
db_clear_session_state(session_id)
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
db_save_chat_message(session_id, "assistant", reply_text)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False)
else:
err_reply = "⚠️ Нет активного превью для подтверждения."
db_save_chat_message(session_id, "assistant", err_reply)
return err_reply, db_get_chat_history(session_id), None
elif fn_name == "db_cancel_prompt_preview":
db_clear_session_state(session_id)
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
db_save_chat_message(session_id, "assistant", reply_text)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False)
# --- 5.2. СТАНДАРТНЫЙ РОУТИНГ ИНСТРУМЕНТОВ ---
elif fn_name == "db_get_snapshots":
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
elif fn_name == "db_get_current_server_time":
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
elif fn_name == "db_get_tasks":
tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False)
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False)
elif fn_name == "db_get_stats":
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
elif fn_name == "db_get_anomalies":
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
elif fn_name == "db_get_session_states":
tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
elif fn_name == "db_delete_snapshots":
res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str"))
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
db_save_chat_message(session_id, "assistant", reply_text)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_get_reference":
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
# --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC]
elif fn_name == "db_preview_prompt_merge":
proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or ""
if isinstance(fn_args, str):
proposed_text = fn_args
try:
fn_args = json.loads(fn_args)
except Exception:
fn_args = {}
current_prompt = db_get_active_system_prompt()
user_msg_lower = user_message.lower()
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
# 1. ОБРАБОТКА УДАЛЕНИЯ ПУНКТА
if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]):
target_num_match = re.search(r'\d+(\.\d+)*', user_message)
target_num = target_num_match.group(0) if target_num_match else ""
# Ротация контекста
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
session_state = None
mark_last_user_message_ephemeral(session_id)
lines = current_prompt.splitlines()
if target_num:
new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")]
else:
new_lines = lines
proposed_text = "\n".join(new_lines)
# 1. Задачи (Просмотр)
if fn_name == "db_get_tasks":
raw_tasks = db_get_tasks(user_id, status=fn_args.get("status"))
reply_text = "Вот интерактивный список ваших текущих задач:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
# 2. ОБРАБОТКА ДОБАВЛЕНИЯ / ИЗМЕНЕНИЯ ПУНКТА
elif proposed_text:
if len(proposed_text) < 500:
clean_item = proposed_text.strip()
for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]:
if prefix in clean_item.lower():
clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:")
# 2. Единый диспетчер задач
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]:
action = fn_args.get("action", "UPDATE").upper()
if fn_name == "db_add_task": action = "ADD"
elif fn_name == "db_delete_task": action = "DELETE"
elif fn_name == "db_update_task_status": action = "UPDATE"
lines = current_prompt.splitlines()
new_lines = []
inserted = False
for line in lines:
new_lines.append(line)
if "3.3." in line and not inserted:
item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}"
new_lines.append(f" {item_str}")
inserted = True
if not inserted:
new_lines.append(f" {clean_item}")
proposed_text = "\n".join(new_lines)
db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text)
preview_reply = (
f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n"
f"{proposed_text}\n\n"
f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
)
db_save_chat_message(session_id, "assistant", preview_reply)
return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
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": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"}
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
elif fn_name == "db_get_rules":
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
elif action == "EXPORT":
export_res = db_export_tasks_markdown(
user_id=user_id,
filename=fn_args.get("filename") or "ROADMAP.md",
status_filter=fn_args.get("status")
)
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
elif fn_name == "db_add_task":
res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_update_task_status":
res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_delete_task":
res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
db_save_chat_message(session_id, "assistant", reply_text)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
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")
}
tool_result_content = json.dumps(res, ensure_ascii=False)
return reply_text, db_get_chat_history(session_id), action_payload
else:
res = db_tasks_edit(
user_id=user_id,
action=action,
task_id=fn_args.get("task_id"),
title=fn_args.get("title"),
priority=fn_args.get("priority", "MEDIUM"),
status=fn_args.get("status"),
module=fn_args.get("module", "general"),
due_date=fn_args.get("due_date")
)
raw_tasks = db_get_tasks(user_id)
reply_text = res.get("message", "Операция над задачами выполнена.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
# 3. Системный промпт
elif fn_name == "db_get_system_prompt":
active_prompt = db_get_active_system_prompt()
reply_text = (
"📋 **АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ:**\n\n"
f"```text\n{active_prompt}\n```\n\n"
"Вы можете добавить, отредактировать или удалить любой пункт."
)
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "PROMPT_VIEW",
"buttons": [
{"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"},
{"label": "База знаний", "value": "покажи правила компании", "style": "secondary"}
]
}
# 4. База знаний и правила компании (терминальный возврат)
elif fn_name == "db_get_rules":
rules_data = db_get_rules()
if isinstance(rules_data, dict) and "rules" in rules_data:
rules_list = rules_data["rules"]
elif isinstance(rules_data, list):
rules_list = rules_data
else:
rules_list = [str(rules_data)]
formatted_rules = "\n\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
reply_text = (
"📖 **БАЗА ЗНАНИЙ И ПРАВИЛА КОМПАНИИ (КАДРОВЫЙ АРБИТРАЖ):**\n\n"
f"```text\n{formatted_rules}\n```"
)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "RULES_VIEW",
"buttons": [
{"label": "✏️ Редактировать правила", "value": "action:open_rules_editor", "style": "primary"},
{"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"}
]
}
# 5. Снапшоты СКУД
elif fn_name == "db_get_snapshots":
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
query_date = snapshots_res.get("query_date", "выбранную дату")
reply_text = f"Реестр срезов СКУД за {query_date}:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": snapshots_res
}
elif fn_name == "db_delete_snapshots":
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
raw_ids = fn_args.get("snapshot_ids") or []
is_confirmed = fn_args.get("confirmed", False)
if raw_id and not raw_ids:
if isinstance(raw_id, str) and "," in raw_id:
raw_ids = [s.strip() for s in raw_id.split(",")]
else:
raw_ids = [raw_id]
safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
if not safe_ids:
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
if not is_confirmed:
query_date = state_data.get("query_date", "")
db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
"snapshot_ids": safe_ids,
"query_date": query_date,
"idle_turns": 0
})
ids_str = ", ".join(safe_ids)
reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOT_DELETE_CONFIRM",
"buttons": [
{"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
else:
db_delete_snapshots(snapshot_ids=safe_ids)
query_date = state_data.get("query_date", "")
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_snapshots_res
}
# 6. Прочие сервисные инструменты
elif fn_name == "db_get_current_server_time":
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
elif fn_name == "db_get_stats":
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
elif fn_name == "db_get_anomalies":
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
elif fn_name == "db_get_reference":
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
else:
tool_result_content = "{}"
messages.append(msg)
messages.append({"role": "tool", "content": tool_result_content})
sec_msg = call_ollama_chat(messages, is_vision=False)
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
# --- [SECTION 7: SECONDARY LLM PASS] --- # ANCHOR[SECONDARY_PASS]
second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options}
sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"})
with urllib.request.urlopen(sec_req) as sec_response:
sec_res_data = json.loads(sec_response.read().decode("utf-8"))
raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")
final_content = clean_raw_tool_tags(clean_output(raw_content))
db_save_chat_message(session_id, "assistant", final_content)
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
return final_content, db_get_chat_history(session_id), None
# Если вызовов функций не было
raw_str = msg.get("content", "").strip().replace("**", "")
content_str = clean_raw_tool_tags(clean_output(raw_str))
final_reply = content_str or "Запрос обработан."
db_save_chat_message(session_id, "assistant", final_reply)
# Свободный диалог (Topic Drift)
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), None
except Exception as ex:
logger.exception(f"Непредвиденная ошибка: {ex}")
logger.exception(f"Непредвиденная ошибка агента: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}"
return error_reply, db_get_chat_history(session_id), None
@@ -0,0 +1,64 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/context_manager.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций
инструментов и содержательного диалога с сохранением Topic Drift.
===============================================================================
"""
import logging
from typing import Dict, Any, Optional, List
from ..db.connection import get_db_connection
from ..db_tools import (
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_clear_session_state,
db_set_session_state
)
logger = logging.getLogger("CONTEXT_MANAGER")
def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
"""
Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1).
При очистке удалятся оба сообщения, не оставляя сирот.
"""
db_save_chat_message(session_id, "user", user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1)
def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
"""
Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0).
Эти сообщения остаются в истории навсегда (включая Topic Drift).
"""
db_save_chat_message(session_id, "user", user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0)
def mark_last_user_message_ephemeral(session_id: str) -> None:
"""Помечает последнее сообщение пользователя как эфемерное при активации инструмента."""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE chat_messages
SET is_ephemeral = 1
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
""", (session_id,))
conn.commit()
def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int:
"""
Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды.
Весь содержательный диалог сохраняется.
"""
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}")
return deleted_count
+317
View File
@@ -0,0 +1,317 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью.
===============================================================================
"""
import difflib
import logging
from typing import Dict, Any, Tuple, Optional
from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
from services.knowledge.service import get_rules, add_rule
from services.tasks.service import get_tasks, delete_task, execute_task_action
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state
from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup
logger = logging.getLogger("FAST_PATH")
def _generate_prompt_diff_html(baseline_text: str, draft_text: str) -> str:
base_lines = [line.rstrip() for line in baseline_text.strip().splitlines()]
draft_lines = [line.rstrip() for line in draft_text.strip().splitlines()]
matcher = difflib.SequenceMatcher(None, base_lines, draft_lines)
diff_html_lines = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
for line in base_lines[i1:i2]:
diff_html_lines.append(line)
elif tag == 'delete':
for line in base_lines[i1:i2]:
diff_html_lines.append(
f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
)
elif tag == 'insert':
for line in draft_lines[j1:j2]:
diff_html_lines.append(
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
)
elif tag == 'replace':
for line in base_lines[i1:i2]:
diff_html_lines.append(
f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
)
for line in draft_lines[j1:j2]:
diff_html_lines.append(
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
)
return "\n".join(diff_html_lines)
def handle_fast_path_intercept(
session_id: str,
user_message: str,
full_user_content: str,
session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
msg_raw = user_message.strip()
msg_lower = msg_raw.lower()
# -------------------------------------------------------------------------
# 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ)
# -------------------------------------------------------------------------
if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]):
filename = "ROADMAP.md"
if " в " in msg_lower:
parts = msg_raw.split(" в ", 1)[1].strip().split()
if parts and ("." in parts[0] or parts[0].endswith("md")):
filename = parts[0]
res = execute_task_action(user_id=1, action="EXPORT", filename=filename)
reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.")
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": res.get("filename", filename),
"download_url": res.get("download_url", "#"),
"tasks_count": res.get("tasks_count", 0)
}
return reply, db_get_chat_history(session_id), action_payload
# -------------------------------------------------------------------------
# 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА)
# -------------------------------------------------------------------------
if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "):
target_snap_id = msg_raw.split()[-1].replace("#", "").strip()
from core.database import load_scud_from_db_by_snapshot
df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id)
if df_snap is None or df_snap.empty:
reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных."
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), None
total_cnt = len(df_snap)
present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0
absent_cnt = total_cnt - present_cnt
snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—'
log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—'
rows_list = []
for _, r in df_snap.iterrows():
rows_list.append({
"fio": r.get('Сотрудник', r.get('fio', '')),
"department": r.get('Подразделение', r.get('department_scud', '—')),
"time_in": r.get('Начало_дня', 'Нет входа'),
"first_activity": r.get('Первая_активность', '—'),
"time_out": r.get('Конец_дня', 'Нет выхода'),
"in_building": r.get('Находился_в_здании', '00:00'),
"is_present": bool(r.get('Пришел', False)),
"anomaly": r.get('anomaly_flag', 'NONE')
})
# Фиксация среза в памяти сессии для последующих вопросов к LLM
db_set_session_state(session_id, "SNAPSHOT_INSPECT", {
"snapshot_id": target_snap_id,
"log_date": log_date,
"snapshot_time": snap_time,
"records": rows_list,
"idle_turns": 0
})
reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})."
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "SNAPSHOT_INSPECT_CARD",
"snapshot_id": target_snap_id,
"log_date": log_date,
"snapshot_time": snap_time,
"total_count": total_cnt,
"present_count": present_cnt,
"absent_count": absent_cnt,
"records": rows_list
}
# -------------------------------------------------------------------------
# 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА
# -------------------------------------------------------------------------
if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]:
active_prompt = get_active_system_prompt()
state_data = session_state.get("data_json") if session_state else {}
draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": draft_text,
"action": "MANUAL_EDIT",
"idle_turns": 0
})
reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:"
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "PROMPT_EDITOR",
"raw_draft": draft_text,
"baseline_prompt": active_prompt
}
# -------------------------------------------------------------------------
# 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ
# -------------------------------------------------------------------------
if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]:
rules_data = get_rules()
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
db_set_session_state(session_id, "RULES_PREVIEW", {
"draft_text": raw_rules_text,
"action": "MANUAL_EDIT_RULES",
"idle_turns": 0
})
reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:"
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "RULES_EDITOR",
"raw_draft": raw_rules_text,
"baseline_prompt": raw_rules_text
}
# -------------------------------------------------------------------------
# 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ
# -------------------------------------------------------------------------
if msg_raw.startswith("action:save_draft_prompt:::"):
new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip()
active_prompt = get_active_system_prompt()
diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": new_draft_content,
"action": "MANUAL_EDIT",
"idle_turns": 0
})
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": new_draft_content,
"baseline_prompt": active_prompt,
"diff_html": diff_html,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
]
}
# -------------------------------------------------------------------------
# 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ
# -------------------------------------------------------------------------
if msg_raw.startswith("action:save_draft_rules:::"):
new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip()
rules_data = get_rules()
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content)
db_set_session_state(session_id, "RULES_PREVIEW", {
"draft_text": new_draft_content,
"action": "MANUAL_EDIT_RULES",
"idle_turns": 0
})
preview_reply = f"Предпросмотр изменений базы знаний компании:\n\n{diff_html}\n\nПодтвердите сохранение или отмените действие."
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": new_draft_content,
"baseline_prompt": baseline_rules,
"diff_html": diff_html,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"}
]
}
if not session_state:
return None
state_type = session_state.get("state_type")
state_data = session_state.get("data_json") or {}
if not isinstance(state_data, dict):
state_data = {}
# -------------------------------------------------------------------------
# 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА
# -------------------------------------------------------------------------
if state_type == "PROMPT_PREVIEW":
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
if is_confirm:
draft_text = state_data.get("draft_text", "")
if draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
db_clear_session_state(session_id)
reply = "✅ Системный промпт успешно сохранен и применен в базе данных."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
db_clear_session_state(session_id)
reply = "❌ Изменения системного промпта отменены."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
# -------------------------------------------------------------------------
# 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ
# -------------------------------------------------------------------------
elif state_type == "RULES_PREVIEW":
is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
if is_confirm:
draft_text = state_data.get("draft_text", "")
lines = [l.strip() for l in draft_text.splitlines() if l.strip()]
for line in lines:
clean_line = line
if line[0].isdigit() and "." in line[:5]:
clean_line = line.split(".", 1)[1].strip()
add_rule(clean_line)
close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS")
db_clear_session_state(session_id)
reply = "✅ База знаний и правила компании успешно сохранены в базе данных."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="RULES_EDIT_CANCELLED")
db_clear_session_state(session_id)
reply = "❌ Изменение правил компании отменено."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
return None
+61
View File
@@ -0,0 +1,61 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/ollama_client.py
ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama.
===============================================================================
"""
# ANCHOR[OLLAMA_CLIENT_IMPORTS]
import json
import urllib.request
import urllib.error
import logging
from typing import Dict, Any, Optional
logger = logging.getLogger("OLLAMA_CLIENT")
OLLAMA_URL = "http://10.121.17.227:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0"
LLM_OPTIONS = {
"num_predict": 8192,
"num_ctx": 8192,
"temperature": 0.0,
"repeat_penalty": 1.0,
"presence_penalty": 0.0,
"top_p": 0.9
}
# ANCHOR[OLLAMA_REQUEST_DISPATCHER]
def call_ollama_chat(
messages: list,
tools: Optional[list] = None,
is_vision: bool = False,
timeout: int = 120
) -> Dict[str, Any]:
"""
Отправляет подготовленный массив сообщений в API Ollama.
Возвращает разобранный словарь сообщения ответа или генерирует исключение.
"""
model_name = VISION_MODEL if is_vision else TEXT_MODEL
payload = {
"model": model_name,
"messages": messages,
"stream": False,
"options": LLM_OPTIONS
}
if tools and not is_vision:
payload["tools"] = tools
req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=timeout) as response:
res_data = json.loads(response.read().decode("utf-8"))
return res_data.get("message", {})
+24 -27
View File
@@ -1,39 +1,36 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Базовая санитарная очистка вывода и тегов инструментов.
===============================================================================
"""
import re
import json
import logging
from typing import List, Dict, Any
logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str:
"""Удаляет сырые теги вызова инструментов и системный шум."""
if not text:
return ""
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
text = re.sub(r'</tool_call>', '', text)
return text.strip()
cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
return cleaned.strip()
def clean_output(text: str) -> str:
if not text:
return text
artifacts = ["почемучто", "почто", "почему что"]
lower_text = text.lower()
for art in artifacts:
if lower_text.startswith(art):
text = text[len(art):].lstrip(",.!?:; -")
return text.strip()
"""Очищает маркеры форматирования."""
return text.strip() if text else ""
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if tool_calls:
return tool_calls
if '{"name":' in raw_text_content or '<tool_call>' in raw_text_content:
try:
match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
if match:
fn_name = match.group(1)
fn_args = json.loads(match.group(2))
return [{"function": {"name": fn_name, "arguments": fn_args}}]
except Exception as parse_err:
logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}")
return tool_calls
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Модель управляется через TOOLS_SCHEMA и системный контекст.
Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
"""
return existing_tool_calls
+4 -13
View File
@@ -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
+70 -12
View File
@@ -1,29 +1,87 @@
"""
FILE: modules/web_api/llm/db/db_chat.py
ROLE: Управление историей сообщений и очисткой эфемерного контекста.
"""
from typing import List, Dict, Any
from .connection import get_db_connection
def db_save_chat_message(session_id: str, role: str, content: str):
if not content:
return
conn = 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(row_factory=True)
cursor = conn.cursor()
cursor.execute("""
INSERT INTO chat_messages (session_id, role, content, created_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
""", (session_id, role, content))
INSERT INTO chat_messages (session_id, role, content, is_ephemeral)
VALUES (?, ?, ?, ?)
""", (session_id, role, content, is_ephemeral))
conn.commit()
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 FROM chat_messages
SELECT role, content, is_ephemeral, created_at
FROM chat_messages
WHERE session_id = ?
ORDER BY id DESC LIMIT ?
ORDER BY id DESC
LIMIT ?
""", (session_id, limit))
rows = cursor.fetchall()
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:
"""
Физически удаляет все временные служебные сообщения выбранной сессии
после завершения сценария работы с инструментом.
"""
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
conn.commit()
conn.close()
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
return deleted
def db_clear_chat_history(session_id: str) -> None:
"""Полная очистка всех сообщений сессии."""
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
+189 -51
View File
@@ -1,6 +1,13 @@
"""
===============================================================================
FILE: modules/web_api/llm/db/db_prompts.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / db
ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes),
базой знаний, реестром действий и сессионными стейтами.
===============================================================================
"""
import re
import json
import logging
from typing import List, Dict, Any, Optional
@@ -8,21 +15,121 @@ from .connection import get_db_connection
logger = logging.getLogger("DB_PROMPTS")
def db_get_active_system_prompt() -> str:
conn = get_db_connection()
def init_prompt_nodes_table():
"""Создает реляционную таблицу узлов промпта и заполняет базовыми данными."""
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1")
row = cursor.fetchone()
conn.close()
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."
cursor.execute("""
CREATE TABLE IF NOT EXISTS system_prompt_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt_name TEXT DEFAULT 'main_agent',
section_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
content TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(prompt_name, section_id, item_id)
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'")
if cursor.fetchone()[0] == 0:
seed_nodes = [
# Раздел 1
(1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"),
(1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."),
(1, 2, "Консультация по внутренним регламентам компании (через db_get_rules). Вопросы по ТК РФ и законам поясняй напрямую."),
(1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."),
(1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."),
(1, 5, "Поддержка диалога с операторами и администраторами системы."),
(1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."),
# Раздел 2
(2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"),
(2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."),
(2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."),
(2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."),
(2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."),
(2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."),
(2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."),
(2, 7, "Запрещено писать названия функций или код вызова текстом на экран."),
(2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."),
# Раздел 3
(3, 0, "ПРАВИЛА СТИЛЯ"),
(3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."),
(3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."),
(3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.")
]
cursor.executemany("""
INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content)
VALUES ('main_agent', ?, ?, ?)
""", seed_nodes)
conn.commit()
def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str:
"""Собирает структурированный текст промпта из реляционной таблицы узлов."""
init_prompt_nodes_table()
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = ? AND is_active = 1
ORDER BY section_id ASC, item_id ASC
""", (prompt_name,))
rows = cursor.fetchall()
if not rows:
return "Ты — ИИ-ассистент SCUD Orion AI."
lines = []
current_section = None
for sec_id, itm_id, content in rows:
if itm_id == 0:
if current_section is not None:
lines.append("")
lines.append(f"{sec_id}. {content}")
current_section = sec_id
else:
lines.append(f" {sec_id}.{itm_id}. {content}")
return "\n".join(lines)
def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"):
"""Прямое добавление, изменение или удаление узла в БД."""
init_prompt_nodes_table()
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
action_clean = action.upper()
if action_clean in ["ADD", "UPDATE"]:
cursor.execute("""
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
content = excluded.content,
is_active = 1,
updated_at = CURRENT_TIMESTAMP
""", (prompt_name, section_id, item_id, content))
elif action_clean == "DELETE":
cursor.execute("""
DELETE FROM system_prompt_nodes
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
""", (prompt_name, section_id, item_id))
conn.commit()
def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
"""Получение шаблона и кнопок действия для инструмента из SQLite."""
conn = get_db_connection()
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("""
SELECT tool_name, category, bypass_llm, success_template,
follow_up_question, action_type, buttons_json
follow_up_question, action_type, buttons_json, is_ephemeral
FROM tool_action_registry
WHERE tool_name = ? AND is_active = 1
""", (tool_name,))
@@ -35,40 +142,21 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
return res
return None
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("BEGIN IMMEDIATE;")
cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
existing = cursor.fetchone()
if existing:
cursor.execute(
"UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
(prompt_text, name)
)
else:
cursor.execute(
"INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)",
(name, prompt_text)
)
conn.commit()
return {"status": "success", "message": "Системный промпт успешно обновлен"}
except Exception as e:
logger.error(f"Ошибка при сохранении промпта в БД: {e}")
return {"status": "error", "error": str(e)}
def db_get_rules() -> List[Dict[str, Any]]:
conn = get_db_connection()
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
def db_set_session_state(session_id: str, state_type: str, data: str):
conn = get_db_connection()
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
"""Сохраняет состояние сессии в SQLite."""
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "")
cursor.execute("""
INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
@@ -76,37 +164,40 @@ def db_set_session_state(session_id: str, state_type: str, data: str):
state_type = excluded.state_type,
pending_data = excluded.pending_data,
updated_at = CURRENT_TIMESTAMP
""", (session_id, state_type, data))
""", (session_id, state_type, payload_str))
conn.commit()
conn.close()
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
conn = get_db_connection()
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,))
cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,))
row = cursor.fetchone()
conn.close()
return dict(row) if row else None
if not row:
return None
res = dict(row)
raw_data = res.get("pending_data") or ""
try:
res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None
except Exception:
res["data_json"] = None
return res
def db_clear_session_state(session_id: str):
conn = get_db_connection()
def db_clear_session_state(session_id: str) -> None:
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
conn.commit()
conn.close()
def db_get_session_states() -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
rows = cursor.fetchall()
conn.close()
return {"status": "success", "active_sessions": [dict(r) for r in rows]}
def db_get_stats() -> Dict[str, Any]:
conn = get_db_connection()
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks']
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks']
stats = {}
for t in tables:
try:
@@ -117,8 +208,9 @@ def db_get_stats() -> Dict[str, Any]:
conn.close()
return {"status": "success", "tables_stats": stats}
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection()
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
params = []
@@ -132,8 +224,9 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
conn.close()
return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]}
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection()
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
query = "SELECT category, title, example_prompt, description FROM system_reference"
params = []
@@ -145,3 +238,48 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
rows = cursor.fetchall()
conn.close()
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
"""Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов."""
try:
init_prompt_nodes_table()
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,))
current_sec = 1
current_itm = 0
for raw_line in prompt_text.splitlines():
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
if not clean_line:
continue
# 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст")
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
# 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ")
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
if sub_match:
current_sec = int(sub_match.group(1))
current_itm = int(sub_match.group(2))
content = sub_match.group(3).strip()
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
# Заголовок раздела (обычно капсом)
current_sec = int(sec_match.group(1))
current_itm = 0
content = sec_match.group(2).strip()
else:
current_itm += 1
content = clean_line
cursor.execute("""
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
VALUES (?, ?, ?, ?, 1)
""", (name, current_sec, current_itm, content))
conn.commit()
return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"}
except Exception as e:
logger.error(f"Ошибка при разборе промпта в узлы: {e}")
return {"status": "error", "error": str(e)}
-49
View File
@@ -1,49 +0,0 @@
"""
FILE: modules/web_api/llm/db/db_snapshots.py
"""
import json
from typing import Dict, Any, Optional
from .connection import get_db_connection
from .db_prompts import db_set_session_state
def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count FROM scud_logs "
params = []
if date_str:
iso_date = date_str
if "." in date_str:
parts = date_str.split(".")
if len(parts) == 3:
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
params.extend([date_str, iso_date, f"{iso_date}%"])
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
cursor.execute(query, params)
rows = cursor.fetchall()
snapshots = [dict(r) for r in rows]
result_data = {
"query_date": date_str or "все",
"snapshots_count": len(snapshots),
"snapshots": snapshots
}
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=json.dumps(result_data, ensure_ascii=False))
conn.close()
return result_data
def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
if not snapshot_id and not day_str:
return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
conn = get_db_connection()
cursor = conn.cursor()
if snapshot_id:
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
else:
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
deleted = cursor.rowcount
conn.commit()
conn.close()
return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
-82
View File
@@ -1,82 +0,0 @@
"""
FILE: modules/web_api/llm/db/db_tasks.py
"""
import re
from typing import List, Dict, Any, Optional
from .connection import get_db_connection
def normalize_task_id(task_id_input: str) -> str:
if not task_id_input:
return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
if clean_id.isdigit():
num = int(clean_id)
return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
return f"TASK-{clean_id}"
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT MAX(id) FROM tasks")
max_id = cursor.fetchone()[0] or 0
new_task_id = f"TASK-{(max_id + 1):02d}"
cursor.execute("""
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?)
""", (new_task_id, module, title, priority.upper(), due_date, user_id))
conn.commit()
conn.close()
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
formatted_id = normalize_task_id(task_id)
if due_date:
cursor.execute("""
UPDATE tasks
SET status = ?, due_date = ?
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
else:
cursor.execute("""
UPDATE tasks
SET status = ?
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0:
conn.close()
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
conn.commit()
conn.close()
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
formatted_id = normalize_task_id(task_id)
cursor.execute("""
DELETE FROM tasks
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0:
conn.close()
return {"error": f"Задача {task_id} не найдена"}
conn.commit()
conn.close()
return {"status": "success", "message": f"Задача {formatted_id} удалена"}
+87 -20
View File
@@ -1,32 +1,99 @@
"""
===============================================================================
FILE: modules/web_api/llm/db_tools.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm
ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).
AI-CONTEXT-ANCHORS:
- ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
===============================================================================
"""
from datetime import datetime
from .db.connection import DB_PATH, get_db_connection
from .db.db_chat import db_save_chat_message, db_get_chat_history
from .db.db_tasks import normalize_task_id, db_get_tasks, db_add_task, db_update_task_status, db_delete_task
from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
# ANCHOR[FACADE_EXPORTS]
from typing import Optional, List, Dict, Any
from core.connection import DB_PATH, get_connection as get_db_connection
# Домен: Задачи
from services.tasks.service import (
get_tasks as db_get_tasks,
add_task as db_add_task,
update_task_details as db_update_task_details,
delete_task as db_delete_task,
execute_task_action as db_tasks_edit
)
# Защитный алиас для обратной совместимости
db_update_task_status = db_update_task_details
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
from services.tasks.repository import normalize_task_id
# Домен: Системный промпт
from services.prompts.service import (
get_active_system_prompt as db_get_active_system_prompt,
apply_prompt_action as db_apply_prompt_node_action,
save_full_prompt_draft,
create_prompt_preview
)
# Домен: База знаний
from services.knowledge.service import (
get_rules as db_get_rules,
add_rule as db_add_rule
)
# Чат, сессии, статистика
from .db.db_chat import (
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_clear_chat_history
)
from .db.db_prompts import (
db_get_active_system_prompt,
db_add_system_prompt,
db_get_tool_action,
db_get_rules,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
db_get_session_states,
db_get_stats,
db_get_anomalies,
db_get_reference,
db_get_tool_action
db_get_reference
)
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
def db_get_current_server_time():
now = datetime.now()
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
return {
"current_date": now.strftime("%d.%m.%Y"),
"current_time": now.strftime("%H:%M:%S"),
"day_of_week": days_ru[now.weekday()],
"iso_date": now.strftime("%Y-%m-%d")
}
def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
"""Совместимая обертка для сохранения системного промпта."""
if draft_text is not None:
save_full_prompt_draft(draft_text, prompt_name=name_or_text)
else:
save_full_prompt_draft(name_or_text, prompt_name="main_agent")
def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]:
"""Совместимый фасад выборки снапшотов с сохранением стейта сессии."""
from services.snapshots.service import get_snapshots_registry
from .core.calendar_utils import parse_relative_date_ru
clean_date = (date_str or "").strip()
if not clean_date and original_user_message:
clean_date = parse_relative_date_ru(original_user_message)
res = get_snapshots_registry(date_str=clean_date if clean_date else None)
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res)
return res
def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]:
"""Совместимый фасад безопасного удаления снапшотов."""
from services.snapshots.service import delete_snapshots_safely
target_ids = []
if snapshot_ids:
target_ids.extend(snapshot_ids)
if snapshot_id:
if isinstance(snapshot_id, str) and "," in snapshot_id:
target_ids.extend([s.strip() for s in snapshot_id.split(",")])
else:
target_ids.append(snapshot_id)
return delete_snapshots_safely(snapshot_ids=target_ids)
+155 -118
View File
@@ -1,81 +1,162 @@
"""
===============================================================================
FILE: modules/web_api/llm/schemas.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm
ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama.
AI-CONTEXT-ANCHORS:
- ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта.
- ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами.
- ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД.
===============================================================================
"""
# ANCHOR[SCHEMA_PROMPT_EDIT]
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_get_tasks",
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
"name": "db_prompt_node_edit",
"description": (
"Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
"Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
"При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
),
"parameters": {
"type": "object",
"properties": {
"status": {
"action": {
"type": "string",
"description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED."
}
}
}
"enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
"description": "Тип действия"
},
"section_id": {
"type": "integer",
"description": "Номер раздела (например 1)"
},
"item_id": {
"type": "integer",
"description": "Номер пункта (например 8)"
},
"nodes_list": {
"type": "array",
"items": {"type": "string"},
"description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
},
"content": {
"type": "string",
"description": "Текст пункта"
}
},
{
"type": "function",
"function": {
"name": "db_get_rules",
"description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.",
"parameters": {"type": "object", "properties": {}}
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "db_get_system_prompt",
"description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.",
"description": "Просмотр текущего активного системного промпта ассистента из базы данных.",
"parameters": {
"type": "object",
"properties": {},
"required": []
"properties": {}
}
}
},
# ANCHOR[SCHEMA_TASKS_EDIT]
{
"type": "function",
"function": {
"name": "db_get_tasks",
"description": (
"Просмотр реестра задач и бэклога текущего пользователя.\n"
"Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n"
"Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}."
),
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
"description": "Опциональный фильтр статуса задач (по умолчанию ALL)"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_update_task_status",
"description": "Изменить статус и/или срок выполнения задачи в реестре.",
"name": "db_tasks_edit",
"description": (
"Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n"
"СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n"
"- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n"
"- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n"
"- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n"
"- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент."
),
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"},
"status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"},
"due_date": {"type": "string", "description": "Срок выполнения задачи"}
"action": {
"type": "string",
"enum": ["ADD", "UPDATE", "DELETE", "EXPORT"],
"description": "Тип действия: ADD, UPDATE, DELETE или EXPORT"
},
"required": ["task_id"]
}
"task_id": {
"type": "string",
"description": "Номер задачи (например: '37')"
},
"title": {
"type": "string",
"description": "Описание или текст задачи"
},
"status": {
"type": "string",
"enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
"description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
},
"priority": {
"type": "string",
"enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
"description": "Приоритет задачи"
},
"module": {
"type": "string",
"description": "Модуль проекта"
},
"due_date": {
"type": "string",
"description": "Срок в формате ГГГГ-ММ-ДД"
},
"filename": {
"type": "string",
"description": "Имя файла для экспорта"
}
},
{
"type": "function",
"function": {
"name": "db_delete_task",
"description": "Удалить задачу из реестра по её task_id (например, TASK-18).",
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"}
},
"required": ["task_id"]
"required": ["action"]
}
}
},
# ANCHOR[SCHEMA_SNAPSHOTS]
{
"type": "function",
"function": {
"name": "db_get_snapshots",
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
"description": (
"Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
"ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
"КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
),
"parameters": {
"type": "object",
"properties": {
"date_str": {
"type": "string",
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
"description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
}
}
}
@@ -85,12 +166,22 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
"description": "Удаление дневных снапшотов СКУД по идентификатору или дате.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
"snapshot_id": {
"type": "string",
"description": "Идентификатор или список идентификаторов через запятую"
},
"day_str": {
"type": "string",
"description": "Дата всех снапшотов за день"
},
"confirmed": {
"type": "boolean",
"description": "Флаг окончательного подтверждения удаления пользователем"
}
}
}
}
@@ -98,29 +189,31 @@ TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_get_current_server_time",
"description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
"name": "db_get_anomalies",
"description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
"parameters": {
"type": "object",
"properties": {},
"required": []
"properties": {
"date_str": {
"type": "string",
"description": "Опциональная дата в формате ДД.ММ.ГГГГ"
},
"limit": {
"type": "integer",
"description": "Лимит записей"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_add_task",
"description": "Добавить новую задачу в бэклог проекта.",
"name": "db_get_rules",
"description": "Просмотр базы знаний и правил кадрового арбитража компании.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Краткое описание задачи"},
"priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"},
"module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"},
"due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"}
},
"required": ["title"]
"properties": {}
}
}
},
@@ -128,94 +221,38 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_get_stats",
"description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_anomalies",
"description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
"description": "Получение статистики количества записей в таблицах базы данных.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
"date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
}
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_session_states",
"description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.",
"parameters": {"type": "object", "properties": {}}
"name": "db_get_current_server_time",
"description": "Получение текущего точного времени сервера.",
"parameters": {
"type": "object",
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_reference",
"description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.",
"description": "Справка о возможностях ассистента и примеры доступных команд.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр."
"description": "Категория справки"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_add_system_prompt",
"description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"},
"prompt_text": {"type": "string", "description": "Полный текст системного промпта"}
},
"required": ["prompt_text"]
}
}
},
{
"type": "function",
"function": {
"name": "db_preview_prompt_merge",
"description": "ВЫЗЫВАЙ ПРИ ЛЮБЫХ ИЗМЕНЕНИЯХ ПРОМПТА: добавление пункта ('добавь пункт...'), удаление пункта ('удали пункт 3.4', 'убери 3.4' или других номеров) или редактирование текста промпта. Передавай текст действия или номер удаляемого пункта в prompt_text.",
"parameters": {
"type": "object",
"properties": {
"prompt_text": {
"type": "string",
"description": "Текст нового пункта или команда/номер удаляемого пункта (например '3.4' или 'удали пункт 3.4')"
}
},
"required": ["prompt_text"]
}
}
},
{
"type": "function",
"function": {
"name": "db_confirm_prompt_preview",
"description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_cancel_prompt_preview",
"description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.",
"parameters": {"type": "object", "properties": {}}
}
}
]
+52 -249
View File
@@ -2,68 +2,51 @@
===============================================================================
FILE: modules/web_api/main.py
PROJECT: SCUD Orion AI (Unified Repository)
MODULE: web_api (FastAPI REST Server & Context Management)
ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt),
маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами.
AI-CONTEXT-ANCHORS & INVARIANTS:
- ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов
независимо от рабочей директории запуска (root или web_api).
- ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного
рендеринга интерфейса и ассетов (css/js/favicon).
- ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub).
- ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и
генерации ответов LLM (agent.process_chat_message).
DEPENDENCIES:
- modules/web_api/llm/agent.py (process_chat_message)
- modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH)
- modules/web_api/llm/file_parser.py (extract_text_from_file)
MODULE: web_api (Main Application Entry Point)
ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
===============================================================================
"""
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH]
# ANCHOR[APP_INIT_IMPORTS]
import os
import sys
import json
import sqlite3
import logging
import urllib.request
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR)
import jwt
from passlib.context import CryptContext
from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
for p in [ROOT_DIR, CURRENT_DIR]:
if p not in sys.path:
sys.path.insert(0, p)
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel
from routers.manual_absences import router as manual_absences_router
# Внутренние модули LLM и БД
from llm.agent import process_chat_message
from llm.db_tools import db_get_tasks, DB_PATH
from llm.file_parser import extract_text_from_file
from routers.auth import router as auth_router
from routers.admin import router as admin_router
from routers.tasks import router as tasks_router
from routers.chat import router as chat_router
from routers.files import router as files_router
from routers.exceptions import router as exceptions_router
from routers.snapshots import router as snapshots_router
from routers.remote_workers import router as remote_workers_router
from routers.context import router as context_router
from routers.presence import router as presence_router
from routers.reports import router as reports_router
# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG]
# ANCHOR[APP_CONFIG]
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()]
)
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
STATIC_DIR = os.path.join(CURRENT_DIR, "static")
app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
@@ -79,57 +62,23 @@ async def validation_exception_handler(request, exc):
content={"detail": exc.errors(), "body": str(exc)}
)
# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS]
def get_db():
"""Создает безопасное соединение с SQLite БД модуля."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ANCHOR[ROUTER_REGISTRATION]
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(tasks_router)
app.include_router(chat_router)
app.include_router(files_router)
app.include_router(exceptions_router)
app.include_router(snapshots_router)
app.include_router(remote_workers_router)
app.include_router(context_router)
app.include_router(manual_absences_router)
app.include_router(presence_router)
app.include_router(reports_router)
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"exp": datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
username = payload.get("username")
is_admin = bool(payload.get("is_admin", False))
return {"id": user_id, "username": username, "is_admin": is_admin}
except Exception as e:
logging.warning(f"Auth error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный или просроченный токен авторизации",
headers={"WWW-Authenticate": "Bearer"},
)
# Pydantic-схемы валидации запросов
class AuthRequest(BaseModel):
username: str
password: str
class CreateUserRequest(BaseModel):
username: str
password: str
full_name: Optional[str] = None
is_admin: Optional[bool] = False
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT]
# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
def read_root():
"""Отдает главную страницу панели управления."""
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
@@ -142,175 +91,29 @@ async def favicon():
return FileResponse(file_path)
raise HTTPException(status_code=404)
# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT]
@app.post("/api/v1/auth/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
logging.info(f"===> Попытка входа для пользователя: {username}")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]):
logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@app.post("/api/v1/auth/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not req.new_password or len(req.new_password) < 4:
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
user = cursor.fetchone()
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
conn.close()
raise HTTPException(status_code=400, detail="Неверный старый пароль")
new_hash = pwd_context.hash(req.new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
conn.commit()
conn.close()
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"}
@app.get("/api/v1/admin/users")
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
users = [dict(r) for r in cursor.fetchall()]
conn.close()
return users
@app.post("/api/v1/admin/users")
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
username = req.username.strip().lower()
if not username or not req.password:
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
pwd_hash = pwd_context.hash(req.password)
full_name = req.full_name.strip() if req.full_name else None
is_admin = 1 if req.is_admin else 0
cursor.execute(
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
(username, pwd_hash, full_name, is_admin)
)
conn.commit()
conn.close()
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
return {"status": "success", "message": f"Пользователь {username} создан"}
@app.delete("/api/v1/admin/users/{user_id}")
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
if user_id == current_user["id"]:
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
logging.info(f"Удален пользователь ID: {user_id}")
return {"status": "success", "message": "Пользователь удален"}
# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE]
@app.get("/api/v1/tasks")
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
"""Получить задачи текущего авторизованного пользователя."""
return db_get_tasks(user_id=user["id"])
# --- ЧАТ С АВТОРИЗАЦИЕЙ ---
@app.post("/api/v1/chat")
async def chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None),
current_user: dict = Depends(get_current_user)
):
parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history, action_type = process_chat_message(
user_id=current_user["id"],
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history, "action_type": action_type}
# --- ГОСТЕВОЙ ЧАТ ---
@app.post("/api/v1/chat/guest")
async def guest_chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None)
):
parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history, action_type = process_chat_message(
user_id=0,
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history, "action_type": action_type}
# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK]
@app.get("/{file_path:path}")
def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/")
# Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов
forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"]
if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns):
raise HTTPException(status_code=403, detail="Access denied")
target = os.path.join(STATIC_DIR, clean_path)
if os.path.isfile(target):
if clean_path.endswith(".js"):
return FileResponse(target, media_type="application/javascript")
elif clean_path.endswith(".css"):
return FileResponse(target, media_type="text/css")
return FileResponse(target)
filename = os.path.basename(clean_path)
target_js = os.path.join(STATIC_DIR, "js", filename)
if filename.endswith(".js") and os.path.isfile(target_js):
return FileResponse(target_js, media_type="application/javascript")
target_css = os.path.join(STATIC_DIR, "css", filename)
if filename.endswith(".css") and os.path.isfile(target_css):
return FileResponse(target_css, media_type="text/css")
for root, _, files in os.walk(STATIC_DIR):
if filename in files:
full_path = os.path.join(root, filename)
media = "application/javascript" if filename.endswith(".js") else "text/css"
return FileResponse(full_path, media_type=media)
raise HTTPException(status_code=404, detail="File not found")
+83
View File
@@ -0,0 +1,83 @@
"""
===============================================================================
FILE: modules/web_api/routers/admin.py
ROLE: Администрирование пользователей и прав доступа.
===============================================================================
"""
# ANCHOR[ADMIN_ROUTER_IMPORTS]
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .auth import get_current_user, get_db, pwd_context
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
# ANCHOR[ADMIN_SCHEMAS]
class CreateUserRequest(BaseModel):
username: str
password: str
full_name: Optional[str] = None
is_admin: Optional[bool] = False
# ANCHOR[ADMIN_ENDPOINTS]
@router.get("/users")
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
users = [dict(r) for r in cursor.fetchall()]
conn.close()
return users
@router.post("/users")
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
username = req.username.strip().lower()
if not username or not req.password:
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
pwd_hash = pwd_context.hash(req.password)
full_name = req.full_name.strip() if req.full_name else None
is_admin = 1 if req.is_admin else 0
cursor.execute(
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
(username, pwd_hash, full_name, is_admin)
)
conn.commit()
conn.close()
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
return {"status": "success", "message": f"Пользователь {username} создан"}
@router.delete("/users/{user_id}")
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
if user_id == current_user["id"]:
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
logging.info(f"Удален пользователь ID: {user_id}")
return {"status": "success", "message": "Пользователь удален"}
+117
View File
@@ -0,0 +1,117 @@
"""
===============================================================================
FILE: modules/web_api/routers/auth.py
ROLE: Аутентификация, валидация JWT-токенов и управление паролями.
===============================================================================
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
import jwt
from passlib.context import CryptContext
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from core.connection import get_connection
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def get_db():
return get_connection(row_factory=True)
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"exp": datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
username = payload.get("username")
is_admin = bool(payload.get("is_admin", False))
return {"id": user_id, "username": username, "is_admin": is_admin}
except Exception as e:
logging.warning(f"Auth error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный или просроченный токен авторизации",
headers={"WWW-Authenticate": "Bearer"},
)
class AuthRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@router.post("/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
# Возвращаем full_name (если не задано — отдаем username)
full_name = user["full_name"] if user["full_name"] else user["username"]
return {
"status": "success",
"token": token,
"username": user["username"],
"full_name": full_name,
"user_id": user["id"],
"is_admin": is_admin
}
@router.post("/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not req.new_password or len(req.new_password) < 4:
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
user = cursor.fetchone()
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
conn.close()
raise HTTPException(status_code=400, detail="Неверный старый пароль")
new_hash = pwd_context.hash(req.new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
conn.commit()
conn.close()
return {"status": "success", "message": "Пароль успешно изменен"}
+148
View File
@@ -0,0 +1,148 @@
"""
===============================================================================
FILE: modules/web_api/routers/chat.py
ROLE: Роутер чата с чистым разделением:
- Диалог и команды СКУД/1С (через agent.py).
- Парсинг и извлечение документов без обрезания (через file_parser.py).
===============================================================================
"""
import os
import shutil
import logging
from typing import Optional
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from llm.agent import process_chat_message
from llm.file_parser import parse_uploaded_file
from config import BASE_DIR
logger = logging.getLogger("CHAT_API")
router = APIRouter(prefix="/api/v1", tags=["Chat"])
UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads")
os.makedirs(UPLOAD_TMP_DIR, exist_ok=True)
class ChatMessageRequest(BaseModel):
message: str
session_id: Optional[str] = "web_session_main"
user_id: Optional[int] = 1
def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
if explicit_user_id and explicit_user_id > 0:
return explicit_user_id
if authorization and authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "").strip()
if token.isdigit():
return int(token)
return 1
@router.post("/chat")
async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)):
user_id = resolve_user_id(authorization, payload.user_id)
session_id = payload.session_id or "web_session_main"
user_msg = payload.message.strip()
if not user_msg:
raise HTTPException(status_code=400, detail="Пустое сообщение")
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
session_id=session_id
)
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
@router.post("/chat/upload")
async def chat_upload_endpoint(
file: UploadFile = File(...),
message: Optional[str] = Form(""),
session_id: Optional[str] = Form("web_session_main"),
authorization: Optional[str] = Header(None)
):
user_id = resolve_user_id(authorization, 1)
file_path = os.path.join(UPLOAD_TMP_DIR, file.filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
user_msg = (message or "").strip()
msg_lower = user_msg.lower()
fn_lower = file.filename.lower()
# ⭐️ 1. ПРЯМАЯ ИЗОЛИРОВАННАЯ ОБРАБОТКА PDF/СКАНОВ В WORD ЧЕРЕЗ OFFICE МОДУЛЬ
ocr_keywords = [
"распознай", "распознать", "в word", "в ворд", "для ворда", "для word",
"отформатируй", "текст для вставки", "извлеки текст", "сделай документ", "переведи в ворд"
]
if fn_lower.endswith(".pdf") and (any(k in msg_lower for k in ocr_keywords) or not user_msg):
logger.info(f"[Office] Прямой запуск распознавания PDF в Word: {file.filename}")
from services.office.service import convert_pdf_to_word_service
from modules.web_api.llm.db_tools import db_save_chat_message, db_get_chat_history
# Сохраняем вопрос пользователя в историю
prompt_text = user_msg or f"Распознать документ {file.filename} для MS Word"
db_save_chat_message(session_id, "user", prompt_text, is_ephemeral=0)
# Вызываем офисный сервис постраничного OCR и сборки DOCX
office_res = convert_pdf_to_word_service(file_path, file.filename)
reply_text = (
f"📄 **{office_res['message']}**\n\n"
f"**Фрагмент первой страницы документа:**\n"
f"```text\n{office_res['preview_text']}...\n```\n\n"
f"Файл готов к скачиванию и редактированию в MS Word."
)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": office_res["filename"],
"download_url": office_res["download_url"],
"tasks_count": f"{office_res['total_pages']} стр."
}
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
# 2. Если это не задача OCR в Word — отправляем файл в обычный диалог агента
parsed = parse_uploaded_file(file_path, file.filename)
file_context = parsed.get("context_text", "")
image_b64 = parsed.get("image_b64")
prompt_for_agent = user_msg or f"Проанализируй документ {file.filename}"
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=prompt_for_agent,
file_context=file_context,
image_b64=image_b64,
session_id=session_id
)
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
+50
View File
@@ -0,0 +1,50 @@
"""
===============================================================================
FILE: modules/web_api/routers/context.py
ROLE: REST API мониторинга состояния сессии и очистки памяти диалога.
===============================================================================
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from routers.auth import get_current_user
from modules.web_api.llm.db.db_prompts import db_get_session_state, db_clear_session_state
from modules.web_api.llm.db.db_chat import db_get_chat_history, db_purge_ephemeral_messages, db_clear_chat_history
router = APIRouter(prefix="/api/v1/context", tags=["Context"])
class SessionActionRequest(BaseModel):
session_id: Optional[str] = "web_session_main"
@router.get("/state")
def api_get_context_state(session_id: str = "web_session_main", current_user = Depends(get_current_user)):
state = db_get_session_state(session_id) or {}
history = db_get_chat_history(session_id, limit=50)
ephemeral_count = sum(1 for m in history if dict(m).get("is_ephemeral") == 1)
total_messages = len(history)
return {
"session_id": session_id,
"active_state": state.get("state_type", "IDLE"),
"state_data": state.get("data_json", {}),
"total_messages": total_messages,
"ephemeral_messages": ephemeral_count
}
@router.post("/purge-ephemeral")
def api_purge_ephemeral(req: SessionActionRequest, current_user = Depends(get_current_user)):
purged = db_purge_ephemeral_messages(req.session_id)
return {"status": "success", "purged_count": purged}
@router.post("/clear-all")
def api_clear_all_context(req: SessionActionRequest, current_user = Depends(get_current_user)):
db_clear_session_state(req.session_id)
db_clear_chat_history(req.session_id)
return {"status": "success", "message": "Контекст сессии полностью очищен"}
+34
View File
@@ -0,0 +1,34 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict, List
from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db
router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"])
class ExceptionItem(BaseModel):
category: str
value: str
comment: Optional[str] = ""
@router.get("")
@router.get("/")
def api_get_exceptions():
return get_all_exceptions_from_db()
@router.post("")
@router.post("/")
def api_add_exception(item: ExceptionItem):
if not add_exception_to_db(item.category, item.value, item.comment):
raise HTTPException(status_code=400, detail="Ошибка добавления исключения")
return {"status": "success", "data": item}
@router.delete("")
@router.delete("/")
def api_delete_exception(category: str, value: str):
if not remove_exception_from_db(category, value):
raise HTTPException(status_code=404, detail="Исключение не найдено")
return {"status": "success"}
+115
View File
@@ -0,0 +1,115 @@
"""
===============================================================================
FILE: modules/web_api/routers/files.py
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен.
===============================================================================
"""
import os
import time
import shutil
import urllib.parse
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
router = APIRouter(prefix="/api/v1/files", tags=["Files"])
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
REPORTS_DIR = os.path.join(BASE_ROOT, "output", "reports")
TEMP_REPORTS_DIR = "/tmp/scud_reports"
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
os.makedirs(REPORTS_DIR, exist_ok=True)
os.makedirs(TEMP_REPORTS_DIR, exist_ok=True)
SESSION_TTL_HOURS = 24
def purge_old_tool_sessions(tool_dir_path: str):
if not os.path.exists(tool_dir_path):
return
now = time.time()
cutoff = now - (SESSION_TTL_HOURS * 3600)
try:
for entry in os.listdir(tool_dir_path):
subpath = os.path.join(tool_dir_path, entry)
if os.path.isdir(subpath):
if os.path.getmtime(subpath) < cutoff:
shutil.rmtree(subpath, ignore_errors=True)
except Exception:
pass
@router.get("/download/{tool_name}/{session_uuid}/{filename}")
async def download_file(tool_name: str, session_uuid: str, filename: str):
safe_tool = os.path.basename(tool_name)
safe_uuid = os.path.basename(session_uuid)
safe_filename = os.path.basename(filename)
file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename)
if not os.path.exists(file_path) or not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
media_type = "application/octet-stream"
if safe_filename.endswith((".md", ".txt")):
media_type = "text/markdown; charset=utf-8"
elif safe_filename.endswith(".xlsx"):
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
elif safe_filename.endswith(".pdf"):
media_type = "application/pdf"
encoded_filename = urllib.parse.quote(safe_filename)
return FileResponse(
path=file_path,
media_type=media_type,
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
)
def _find_report_recursively(target_filename: str) -> Optional[str]:
"""Рекурсивный поиск файла отчета по имени в /tmp и во всех подпапках output/reports/."""
# 1. Проверяем /tmp/scud_reports
tmp_path = os.path.join(TEMP_REPORTS_DIR, target_filename)
if os.path.exists(tmp_path) and os.path.isfile(tmp_path):
return tmp_path
# 2. Проверяем прямой путь в output/reports
direct_path = os.path.join(REPORTS_DIR, target_filename)
if os.path.exists(direct_path) and os.path.isfile(direct_path):
return direct_path
# 3. Рекурсивный поиск по подкаталогам (год/месяц)
for root, _, files in os.walk(REPORTS_DIR):
if target_filename in files:
return os.path.join(root, target_filename)
return None
@router.get("/download/reports/{filename:path}")
async def download_report_direct(filename: str):
"""
Прямое скачивание отчетов:
- Декодирует UTF-8 URL (%20 -> пробел).
- Ищет файл в output/reports/{YEAR}/{MONTH} и во временном буфере /tmp/scud_reports.
"""
decoded_name = urllib.parse.unquote(filename).strip()
safe_filename = os.path.basename(decoded_name)
target_path = _find_report_recursively(safe_filename)
if not target_path or not os.path.exists(target_path):
raise HTTPException(status_code=404, detail="Отчет не найден")
encoded_filename = urllib.parse.quote(safe_filename)
return FileResponse(
path=target_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
)
@@ -0,0 +1,88 @@
"""
===============================================================================
FILE: modules/web_api/routers/manual_absences.py
ROLE: REST API эндпоинты для реестров "Мест. командир.", "Иное" и автокомплита.
===============================================================================
"""
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from services.manual_absences_repo import (
search_staff_suggestions,
get_static_reasons,
add_manual_absence,
delete_manual_absence,
get_manual_absences_list
)
router = APIRouter(prefix="/api/v1/manual-absences", tags=["Manual Absences"])
class AddAbsenceRequest(BaseModel):
absence_type: str # 'LOCAL_TRIP' или 'OTHER'
fio: str
reason: Optional[str] = ""
department: Optional[str] = ""
position: Optional[str] = ""
date_start: Optional[str] = None
date_end: Optional[str] = None
comment: Optional[str] = ""
@router.get("/staff-autocomplete")
def api_staff_autocomplete(q: str = Query(..., min_length=2)):
return search_staff_suggestions(q)
@router.get("/reasons")
def api_get_reasons():
return {"reasons": get_static_reasons()}
@router.get("/")
def api_list_manual_absences(type: Optional[str] = None):
return {"items": get_manual_absences_list(type)}
@router.post("/")
def api_add_manual_absence(req: AddAbsenceRequest):
reason = req.reason or ("Местная командировка" if req.absence_type == "LOCAL_TRIP" else "Иное")
res_id = add_manual_absence(
absence_type=req.absence_type,
fio=req.fio,
reason=reason,
department=req.department,
position=req.position,
date_start=req.date_start,
date_end=req.date_end,
comment=req.comment
)
if not res_id:
raise HTTPException(status_code=400, detail="Не удалось добавить запись")
return {"status": "success", "id": res_id}
@router.delete("/{item_id}")
def api_delete_manual_absence(item_id: int):
if not delete_manual_absence(item_id):
raise HTTPException(status_code=404, detail="Запись не найдена")
return {"status": "success"}
class UpdateAbsenceDatesRequest(BaseModel):
id: int
date_start: Optional[str] = None
date_end: Optional[str] = None
@router.put("/{item_id}")
def api_update_manual_absence(item_id: int, req: UpdateAbsenceDatesRequest):
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE manual_absences
SET date_start = ?, date_end = ?
WHERE id = ?
""", (req.date_start, req.date_end, item_id))
conn.commit()
return {"status": "success"}
+26
View File
@@ -0,0 +1,26 @@
"""
===============================================================================
FILE: modules/web_api/routers/presence.py
ROLE: REST API оперативного статуса присутствия сотрудников в здании.
===============================================================================
"""
from fastapi import APIRouter, Query
from typing import Optional, Dict, Any
from services.presence_service import get_live_presence
router = APIRouter(prefix="/api/v1/presence", tags=["Presence"])
@router.get("/live")
def api_get_live_presence(
date_str: Optional[str] = Query(None, description="Дата в формате ДД.ММ.ГГГГ"),
force_refresh: bool = Query(False, description="Принудительный опрос MS SQL Орион")
):
"""
Возвращает оперативный статус сотрудников («Кто в здании»).
По умолчанию возвращает срез моментально из локальной базы SQLite.
При force_refresh=true выполняет опрос турникетов в MS SQL Орион.
"""
return get_live_presence(date_str=date_str, force_refresh=force_refresh)
+157
View File
@@ -0,0 +1,157 @@
"""
===============================================================================
FILE: modules/web_api/routers/remote_workers.py
ROLE: REST API реестра удаленщиков (CRUD, редактирование сроков, автоочистка).
===============================================================================
"""
import os
from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from routers.auth import get_current_user
from config import BASE_DIR, DATA_DIR
router = APIRouter(prefix="/api/v1/remote-workers", tags=["RemoteWorkers"])
CSV_PATH = os.path.join(DATA_DIR, "static_reason_workers.csv")
def _load_workers() -> List[Dict[str, Any]]:
"""Читает CSV, гарантирует структуру колонок и удаляет просроченные записи."""
if not os.path.exists(CSV_PATH):
return []
try:
df = pd.read_csv(CSV_PATH, dtype=str, on_bad_lines='skip').fillna("")
for col in ["fio", "department", "reason", "date_from", "date_to"]:
if col not in df.columns:
df[col] = ""
today_date = datetime.now().date()
valid_workers = []
has_expired = False
for row in df.to_dict(orient="records"):
fio = str(row.get("fio", "")).strip()
if not fio:
continue
d_to_str = str(row.get("date_to", "")).strip()
if d_to_str and d_to_str.lower() not in ["nan", "none", ""]:
try:
d_to = datetime.strptime(d_to_str.replace('_', '.'), "%d.%m.%Y").date()
# Если срок завершился вчера или ранее — запись удаляется из файла
if today_date > d_to:
has_expired = True
continue
except ValueError:
pass
valid_workers.append(row)
# Синхронная перезапись файла при обнаружении истекших сроков
if has_expired:
_save_workers(valid_workers)
return valid_workers
except Exception:
return []
def _save_workers(workers: List[Dict[str, Any]]):
"""Сохраняет актуальный список в CSV с сохранением колонок."""
df = pd.DataFrame(workers)
for col in ["fio", "department", "reason", "date_from", "date_to"]:
if col not in df.columns:
df[col] = ""
os.makedirs(os.path.dirname(CSV_PATH), exist_ok=True)
df.to_csv(CSV_PATH, index=False, encoding="utf-8")
class RemoteWorkerItem(BaseModel):
fio: str
department: Optional[str] = "Все"
reason: Optional[str] = "Удаленная работа"
date_from: Optional[str] = ""
date_to: Optional[str] = ""
class UpdateDatesRequest(BaseModel):
fio: str
date_from: Optional[str] = ""
date_to: Optional[str] = ""
@router.get("")
def api_get_remote_workers(current_user = Depends(get_current_user)):
return {"workers": _load_workers()}
@router.post("")
def api_add_remote_worker(item: RemoteWorkerItem, current_user = Depends(get_current_user)):
workers = _load_workers()
fio_clean = item.fio.strip()
if not fio_clean:
raise HTTPException(status_code=400, detail="ФИО не может быть пустым")
# Если начало не указано — берем сегодня
today_str = datetime.now().strftime("%d.%m.%Y")
date_from = item.date_from.strip() if item.date_from and item.date_from.strip() else today_str
date_to = item.date_to.strip() if item.date_to else ""
# Проверка на совпадение ФИО (обновление существующей записи)
for w in workers:
if str(w.get("fio", "")).strip().lower() == fio_clean.lower():
w["department"] = item.department.strip() if item.department else (w.get("department") or "Все")
w["reason"] = item.reason.strip() if item.reason else (w.get("reason") or "Удаленная работа")
w["date_from"] = date_from
w["date_to"] = date_to
_save_workers(workers)
return {"status": "success", "message": "Срок удаленки обновлен", "workers": workers}
workers.append({
"fio": fio_clean,
"department": item.department.strip() if item.department else "Все",
"reason": item.reason.strip() if item.reason else "Удаленная работа",
"date_from": date_from,
"date_to": date_to
})
_save_workers(workers)
return {"status": "success", "workers": workers}
@router.put("")
def api_update_worker_dates(req: UpdateDatesRequest, current_user = Depends(get_current_user)):
"""Редактирование срока удаленки (продление или сокращение)."""
workers = _load_workers()
target_fio = req.fio.strip().lower()
found = False
for w in workers:
if str(w.get("fio", "")).strip().lower() == target_fio:
w["date_from"] = req.date_from.strip() if req.date_from is not None else w.get("date_from", "")
w["date_to"] = req.date_to.strip() if req.date_to is not None else w.get("date_to", "")
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Сотрудник не найден в списке")
_save_workers(workers)
return {"status": "success", "message": "Сроки успешно изменены", "workers": workers}
@router.delete("")
def api_delete_remote_worker(fio: str, current_user = Depends(get_current_user)):
workers = _load_workers()
fio_clean = fio.strip().lower()
initial_len = len(workers)
workers = [w for w in workers if str(w.get("fio", "")).strip().lower() != fio_clean]
if len(workers) == initial_len:
raise HTTPException(status_code=404, detail="Сотрудник не найден")
_save_workers(workers)
return {"status": "success", "workers": workers}
+128
View File
@@ -0,0 +1,128 @@
"""
===============================================================================
FILE: modules/web_api/routers/reports.py
ROLE: REST API On-Demand генерации отчетов:
- Сводка: за СЕГОДНЯ (оперативный контроль, текущий срез).
- Детальный и Упрощенный отчет: строго за ВЧЕРА по финальному срезу Y.
===============================================================================
"""
import os
import logging
from datetime import datetime, timedelta
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException
from config import DATE_TODAY, DATE_YESTERDAY
from services.scud_etl.svodka_generator import generate_svodka_service
from services.scud_etl.otchet_generator import generate_otchet_service
from services.reports.simplified_builder import generate_simplified_excel
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c
logger = logging.getLogger("REPORTS_API")
router = APIRouter(prefix="/api/v1/reports", tags=["Reports"])
def get_previous_workday(target_date_str: str) -> str:
"""Вычисляет дату предыдущей рабочей смены (в понедельник возвращает пятницу)."""
clean_date = target_date_str.replace('_', '.')
try:
dt = datetime.strptime(clean_date, "%d.%m.%Y")
days_back = 3 if dt.weekday() == 0 else 1
return (dt - timedelta(days=days_back)).strftime("%d.%m.%Y")
except Exception:
return DATE_YESTERDAY
class GenerateReportRequest(BaseModel):
date: Optional[str] = None # ДД.ММ.ГГГГ
time: Optional[str] = None # ЧЧ:ММ
report_type: str # 'SVODKA', 'DETAILED', 'SIMPLIFIED', 'ALL'
@router.post("/generate")
def api_generate_report(req: GenerateReportRequest):
"""
Генерирует выбранный отчет:
- SVODKA: на текущую дату (date).
- DETAILED / SIMPLIFIED: строго за вчерашний рабочий день по финальному срезу Y.
"""
target_date = (req.date or DATE_TODAY).replace('_', '.')
yesterday_date = get_previous_workday(target_date)
r_type = req.report_type.upper()
results = []
# 1. Ежедневная сводка (за СЕГОДНЯ)
if r_type in ["SVODKA", "ALL"]:
res_svodka = generate_svodka_service(
target_date=target_date,
target_time=req.time
)
if res_svodka.get("status") == "success":
results.append({
"type": "Сводка",
"filename": res_svodka.get("filename"),
"download_url": res_svodka.get("download_url"),
"status": "success"
})
else:
results.append({
"type": "Сводка",
"error": res_svodka.get("message"),
"status": "error"
})
# 2. Детальный отчет (строго за ВЧЕРА по финальному срезу Y)
if r_type in ["DETAILED", "ALL"]:
res_det = generate_otchet_service(target_date=yesterday_date)
if res_det.get("status") == "success":
results.append({
"type": "Детальный отчет",
"filename": res_det.get("filename"),
"download_url": res_det.get("download_url"),
"status": "success"
})
else:
results.append({
"type": "Детальный отчет",
"error": res_det.get("message"),
"status": "error"
})
# 3. Упрощенный отчет (строго за ВЧЕРА по финальному срезу Y)
if r_type in ["SIMPLIFIED", "ALL"]:
try:
df_scud_y = load_best_snapshot_for_date(yesterday_date, prefer_final_y=True)
if df_scud_y is not None and not df_scud_y.empty:
df_staff_y, df_abs_y = load_1c_files_for_date(yesterday_date)
df_merged_y = merge_scud_and_1c(df_scud_y, df_staff_y, df_abs_y)
out_path = generate_simplified_excel(df_merged_y, date_str=yesterday_date)
filename = os.path.basename(out_path)
results.append({
"type": "Упрощенный отчет",
"filename": filename,
"download_url": f"/api/v1/files/download/reports/{filename}",
"status": "success"
})
else:
results.append({
"type": "Упрощенный отчет",
"error": f"Финальный срез СКУД (Y) за {yesterday_date} не найден.",
"status": "error"
})
except Exception as e:
logger.exception(f"Ошибка формирования упрощенного отчета: {e}")
results.append({
"type": "Упрощенный отчет",
"error": str(e),
"status": "error"
})
return {
"date": target_date,
"yesterday_date": yesterday_date,
"requested_type": r_type,
"reports": results
}
+400
View File
@@ -0,0 +1,400 @@
"""
===============================================================================
FILE: modules/web_api/routers/snapshots.py
ROLE: Роутер срезов СКУД:
- Список срезов за период дат (/api/v1/snapshots)
- Инспекция среза (/api/v1/snapshots/{snapshot_id}/details и /inspect)
- Экспорт среза в Excel (.xlsx) и CSV (UTF-8 с BOM)
- Ручное создание среза (/create)
- Удаление срезов (пакетное через snapshot_ids)
===============================================================================
"""
import os
import io
import urllib.parse
import logging
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse, FileResponse
from pydantic import BaseModel
import pandas as pd
from core.connection import get_connection
import config
logger = logging.getLogger("SNAPSHOTS_ROUTER")
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
BASE_DIR = getattr(config, "BASE_DIR", Path(__file__).resolve().parent.parent.parent.parent)
SNAPSHOTS_DIR = getattr(config, "SNAPSHOTS_DIR", os.path.join(str(BASE_DIR), "exports", "snapshots"))
DATE_TODAY = getattr(config, "DATE_TODAY", datetime.now().strftime("%d.%m.%Y"))
class CreateSnapshotRequest(BaseModel):
date_str: Optional[str] = None
time_str: Optional[str] = None
class DeleteSnapshotsRequest(BaseModel):
snapshot_ids: List[str]
# =============================================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ РАСЧЕТА СТАТУСА И ВРЕМЕНИ
# =============================================================================
def parse_time_str(t_str: str) -> Optional[datetime]:
"""Парсит строку времени HH:MM[:SS] в datetime объект."""
if not t_str or str(t_str).strip() in ("Нет входа", "Нет выхода", "—", "-", "None", "nan"):
return None
for fmt in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(str(t_str).strip(), fmt)
except ValueError:
pass
return None
def calculate_row_presence(d: dict, snapshot_time_str: str) -> dict:
"""
Определяет реальный статус сотрудника и время нахождения в здании.
"""
fio = d.get("fio") or d.get("Сотрудник") or "—"
dept = d.get("department") or d.get("Подразделение") or "—"
t_in = d.get("time_in") or d.get("Начало_дня") or d.get("first_in") or "Нет входа"
first_act = d.get("first_activity") or d.get("Первая_активность") or "—"
t_out = d.get("time_out") or d.get("Конец_дня") or d.get("last_out") or "Нет выхода"
db_in_bld = d.get("in_building") or d.get("Находился_в_здании")
db_status = d.get("status") or d.get("Статус") or d.get("Пришел")
has_in = t_in not in ("Нет входа", "—", "-", "", None, "None")
has_act = first_act not in ("—", "-", "", None, "None")
has_out = t_out not in ("Нет выхода", "—", "-", "", None, "None")
# 1. Если нет отметок прохода — сотрудник отсутствовал
if not has_in and not has_act and not has_out:
final_status = "Отсутствовал (Нет событий)"
final_in_bld = "00:00"
else:
# Сотрудник присутствовал
final_status = "Присутствовал"
# Расчет времени нахождения в здании
dt_in = parse_time_str(t_in) or parse_time_str(first_act)
dt_out = parse_time_str(t_out)
if db_in_bld and db_in_bld not in ("00:00", "—", "", "None"):
final_in_bld = db_in_bld
elif dt_in:
if dt_out and dt_out >= dt_in:
diff = dt_out - dt_in
else:
# Если выхода еще нет — считаем до момента фиксации среза
dt_snap = parse_time_str(snapshot_time_str) or datetime.now()
if dt_snap >= dt_in:
diff = dt_snap - dt_in
else:
diff = timedelta(0)
total_minutes = int(diff.total_seconds() // 60)
hh = total_minutes // 60
mm = total_minutes % 60
final_in_bld = f"{hh:02d}:{mm:02d}"
else:
final_in_bld = "00:00"
# Сохраняем специальные статусы, если они зафиксированы в БД
if db_status and "Отсутств" in str(db_status):
final_status = db_status
return {
"fio": fio,
"department": dept,
"time_in": t_in,
"first_activity": first_act,
"time_out": t_out,
"in_building": final_in_bld,
"status": final_status
}
# =============================================================================
# 1. СПИСОК СРЕЗОВ (С ПОДДЕРЖКОЙ ДИАПАЗОНА ДАТ)
# =============================================================================
@router.get("", include_in_schema=False)
@router.get("/")
def list_snapshots(
date: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None
):
"""
Возвращает список срезов за диапазон дат со всеми полями для интерфейса.
"""
d_from = (date_from or date or DATE_TODAY).replace('_', '.')
d_to = (date_to or date or DATE_TODAY).replace('_', '.')
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_id, COUNT(*) as cnt, MAX(created_at) as created_at, log_date
FROM scud_logs
WHERE (
substr(log_date, 7, 4) || '-' || substr(log_date, 4, 2) || '-' || substr(log_date, 1, 2)
BETWEEN
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
AND
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
)
AND snapshot_id IS NOT NULL AND snapshot_id != ''
GROUP BY snapshot_id
ORDER BY snapshot_id DESC
""", (d_from, d_from, d_from, d_to, d_to, d_to))
rows = cursor.fetchall()
items = []
for r in rows:
raw_id = r["snapshot_id"]
clean_id = str(raw_id).lstrip("#").strip()
total_cnt = r["cnt"]
time_str = "—"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
time_str = f"{parts[1][:2]}:{parts[1][2:4]}"
is_final = "FINAL" in clean_id.upper()
items.append({
"id": clean_id,
"snapshot_id": clean_id,
"label": clean_id,
"snapshot_time": time_str,
"time": time_str,
"record_count": total_cnt,
"count": total_cnt,
"records_count": total_cnt,
"is_final": is_final,
"date": r["log_date"],
"created_at": r["created_at"] or r["log_date"]
})
return {
"date_from": d_from,
"date_to": d_to,
"total_snapshots": len(items),
"snapshots": items
}
# =============================================================================
# 2. ИНСПЕКЦИЯ СРЕЗА (ДЛЯ МОДАЛЬНОГО ОКНА)
# Поддерживает оба пути: /details и /inspect
# =============================================================================
@router.get("/{snapshot_id}/details")
@router.get("/{snapshot_id}/inspect")
def inspect_snapshot(snapshot_id: str):
clean_id = snapshot_id.lstrip("#").strip()
target_date = ""
snapshot_time = "23:59:59"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts[0]) == 8 and parts[0].isdigit():
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
rows = []
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
# Безопасная выборка всех полей строки среза
cursor.execute("""
SELECT * FROM scud_logs
WHERE snapshot_id IN (?, ?, ?, ?)
ORDER BY id ASC
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
rows = cursor.fetchall()
# Резервный сбор на лету из scud_events_raw, если среза нет в scud_logs
if not rows and target_date:
cursor.execute("""
SELECT
fio,
department,
MIN(CASE WHEN direction = 'IN' THEN time_val END) as time_in,
NULL as first_activity,
MAX(CASE WHEN direction = 'OUT' THEN time_val END) as time_out,
'00:00' as in_building,
'Присутствовал' as status
FROM scud_events_raw
WHERE log_date = ?
GROUP BY fio_clean
ORDER BY fio ASC
""", (target_date,))
rows = cursor.fetchall()
if not rows:
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
records = []
for r in rows:
calc = calculate_row_presence(dict(r), snapshot_time)
records.append({
"hoz_organ": dict(r).get("hoz_organ") or dict(r).get("tab_num") or "",
"fio": calc["fio"],
"Сотрудник": calc["fio"],
"department": calc["department"],
"Подразделение": calc["department"],
"time_in": calc["time_in"],
"Вход": calc["time_in"],
"first_activity": calc["first_activity"],
"time_out": calc["time_out"],
"Выход": calc["time_out"],
"in_building": calc["in_building"],
"Находился_в_здании": calc["in_building"],
"status": calc["status"],
"Пришел": calc["status"]
})
return {
"status": "ok",
"snapshot_id": clean_id,
"date": target_date or DATE_TODAY,
"total_records": len(records),
"count": len(records),
"records": records,
"data": records
}
# =============================================================================
# 3. ЭКСПОРТ ДАННЫХ ИНСПЕКЦИИ СРЕЗА (EXCEL / CSV)
# =============================================================================
@router.get("/{snapshot_id}/export")
def api_export_snapshot(snapshot_id: str, format: str = Query("xlsx")):
clean_id = snapshot_id.lstrip("#").strip()
target_date = ""
snapshot_time = "23:59:59"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts[0]) == 8 and parts[0].isdigit():
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
rows = []
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM scud_logs
WHERE snapshot_id IN (?, ?, ?, ?)
ORDER BY id ASC
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
rows = cursor.fetchall()
if not rows:
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
export_list = []
for r in rows:
calc = calculate_row_presence(dict(r), snapshot_time)
export_list.append({
"Сотрудник": calc["fio"],
"Подразделение": calc["department"],
"Вход": calc["time_in"],
"Первая активность": calc["first_activity"],
"Выход": calc["time_out"],
"В здании": calc["in_building"],
"Статус": calc["status"]
})
out_df = pd.DataFrame(export_list)
filename_base = f"Инспекция_{clean_id}"
if format.lower() == "csv":
csv_bytes = out_df.to_csv(index=False, sep=";", encoding="utf-8-sig").encode("utf-8-sig")
filename = f"{filename_base}.csv"
encoded = urllib.parse.quote(filename)
return StreamingResponse(
io.BytesIO(csv_bytes),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
)
else:
output = io.BytesIO()
with pd.ExcelWriter(output, engine="openpyxl") as writer:
out_df.to_excel(writer, index=False, sheet_name="Срез")
output.seek(0)
filename = f"{filename_base}.xlsx"
encoded = urllib.parse.quote(filename)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
)
# =============================================================================
# 4. РУЧНОЕ СОЗДАНИЕ И ПАКЕТНОЕ УДАЛЕНИЕ СРЕЗОВ
# =============================================================================
@router.post("/create")
def create_snapshot(req: CreateSnapshotRequest):
from services.scud_export import run_export
target_date = (req.date_str or DATE_TODAY).replace('_', '.')
try:
run_export(input_date=target_date, save_xlsx=True, debug=False)
return {"status": "ok", "message": f"Срез за {target_date} успешно создан"}
except Exception as e:
logger.error(f"Ошибка создания среза: {e}")
raise HTTPException(status_code=500, detail=f"Не удалось создать срез: {e}")
@router.delete("", include_in_schema=False)
@router.delete("/")
def delete_snapshots(req: DeleteSnapshotsRequest):
if not req.snapshot_ids:
return {"status": "ok", "deleted": 0}
with get_connection() as conn:
cursor = conn.cursor()
for sid in req.snapshot_ids:
clean_id = sid.lstrip("#").strip()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id IN (?, ?)", (clean_id, f"#{clean_id}"))
conn.commit()
return {"status": "ok", "deleted": len(req.snapshot_ids)}
# =============================================================================
# 5. СКАЧИВАНИЕ ФИЗИЧЕСКИХ ФАЙЛОВ .XLSX (FALLBACK)
# =============================================================================
@router.get("/{filename}")
def download_snapshot_file(filename: str):
safe_filename = os.path.basename(filename)
file_path = os.path.join(SNAPSHOTS_DIR, safe_filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Файл среза не найден на диске")
return FileResponse(
path=file_path,
filename=safe_filename,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
+76
View File
@@ -0,0 +1,76 @@
"""
===============================================================================
FILE: modules/web_api/routers/tasks.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers
ROLE: REST API эндпоинты реестра задач.
===============================================================================
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict, Any
from routers.auth import get_current_user
from services.tasks.service import get_tasks, add_task, update_task_details
router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
class TaskCreateRequest(BaseModel):
title: str
priority: Optional[str] = "MEDIUM"
module: Optional[str] = "general"
due_date: Optional[str] = None
status: Optional[str] = "BACKLOG"
class TaskUpdateRequest(BaseModel):
title: Optional[str] = None
priority: Optional[str] = None
due_date: Optional[str] = None
status: Optional[str] = None
def resolve_user_id(current_user: Dict[str, Any]) -> int:
if not current_user:
return 1
return current_user.get("id") or current_user.get("user_id") or 1
@router.get("")
async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
user_id = resolve_user_id(current_user)
return {"tasks": get_tasks(user_id=user_id, status=status)}
@router.post("")
async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)):
user_id = resolve_user_id(current_user)
res = add_task(
user_id=user_id,
module=req.module,
title=req.title,
priority=req.priority,
due_date=req.due_date,
status=req.status
)
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return res
@router.patch("/{task_id}")
async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)):
user_id = resolve_user_id(current_user)
res = update_task_details(
user_id=user_id,
task_id=task_id,
title=req.title,
priority=req.priority,
status=req.status,
due_date=req.due_date
)
if "error" in res:
raise HTTPException(status_code=404, detail=res["error"])
return res
@@ -1,47 +0,0 @@
import os
import sqlite3
# Автопоиск файла базы данных в проекте
db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'
print("=" * 80)
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
print("=" * 80)
if not os.path.exists(db_path):
print(f"❌ Файл базы данных {db_path} не найден!")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 1. Список всех таблиц и колонок
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [t[0] for t in cursor.fetchall()]
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
print("-" * 80)
for t_name in tables:
cursor.execute(f"PRAGMA table_info({t_name})")
cols = [c[1] for c in cursor.fetchall()]
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
count = cursor.fetchone()[0]
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
# 2. Просмотр правил Базы Знаний
if 'ai_knowledge_base' in tables:
print("\n" + "=" * 80)
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
print("=" * 80)
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
rules = cursor.fetchall()
if not rules:
print("Таблица ai_knowledge_base пуста.")
else:
for r_id, r_text, r_author in rules:
print(f" {r_id}. [{r_author}] {r_text}\n")
conn.close()
print("=" * 80)
@@ -1,23 +0,0 @@
import os
print("=" * 80)
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
print("=" * 80)
total_files = 0
total_size = 0
for root, dirs, files in os.walk('.'):
# Исключаем служебные каталоги
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
for f in files:
p = os.path.join(root, f)
size = os.path.getsize(p)
total_files += 1
total_size += size
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
print("-" * 80)
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
print("=" * 80)
@@ -1,31 +0,0 @@
import os
OUTPUT_SNAPSHOT = "api_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
+273 -209
View File
@@ -1,261 +1,325 @@
<!DOCTYPE html>
<html lang="ru">
<html lang="ru" class="h-full bg-slate-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>SCUD Orion AI — Context Manager</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SCUD Orion AI Assistant</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/styles.css">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<style>
* { overflow-anchor: none !important; }
#chat-messages-container, main { overflow-anchor: none !important; }
#chat-messages-container { padding-bottom: clamp(400px, 85vh, 900px) !important; }
/* ⭐️ Комфортный размер шрифта в стиле Gemini */
#chat-messages-container .message-content,
#chat-messages-container .text-sm,
#chat-messages-container .text-xs,
#chat-messages-container p,
#chat-messages-container li {
font-size: 16px !important;
line-height: 1.65 !important;
color: #1e293b !important; /* slate-800 */
}
#chat-messages-container .user-chat-bubble .text-sm,
#chat-messages-container .user-chat-bubble p,
#chat-messages-container .user-chat-bubble div {
font-size: 16px !important;
line-height: 1.6 !important;
color: #ffffff !important;
}
#chat-messages-container pre,
#chat-messages-container code {
font-size: 14.5px !important;
line-height: 1.5 !important;
}
.user-chat-bubble { scroll-margin-top: 24px !important; }
</style>
</head>
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
<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="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
<div class="flex items-center space-x-3 mb-6">
<div class="bg-indigo-600 text-white p-3 rounded-xl">
<i class="fa-solid fa-user-shield text-xl"></i>
</div>
<div>
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
<p class="text-xs text-slate-500">Авторизация в системе</p>
</div>
</div>
<div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label>
<input type="text" id="auth-username-input" placeholder="Введите логин..." required autocomplete="username"
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
</div>
<!-- ЛЕВАЯ КОЛОНКА (САЙДБАР) -->
<aside class="w-[420px] md:w-[500px] bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
<div>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label>
<input type="password" id="auth-password-input" placeholder="Введите пароль..." required autocomplete="current-password"
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
</div>
<div id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200"></div>
<button type="button" onclick="handleLogin()" id="auth-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl text-sm transition shadow-md flex items-center justify-center gap-2">
<i class="fa-solid fa-right-to-bracket"></i>
<span>Войти в систему</span>
<!-- НАВИГАЦИОННЫЙ ТАБ-БАР -->
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-1 py-1 shrink-0">
<button data-tab="tasks" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-list-check block text-sm mb-0.5"></i> Задачи
</button>
<button data-tab="snapshots" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-camera block text-sm mb-0.5"></i> Срезы
</button>
<button data-tab="registries" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-address-book block text-sm mb-0.5"></i> Реестры
</button>
<button data-tab="prompts" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-terminal block text-sm mb-0.5"></i> Промпт
</button>
<button data-tab="context" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
<i class="fa-solid fa-comments block text-sm mb-0.5"></i> Контекст
</button>
</div>
<div class="relative my-4">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
<!-- РАБОЧИЕ ОБЛАСТИ ВКЛАДОК -->
<div class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
<!-- 1. ЗАДАЧИ -->
<div id="sidebar-view-tasks" class="sidebar-view w-full h-full flex flex-col">
<div id="tasks-list" class="space-y-2 flex-1"></div>
</div>
<button type="button" onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2">
<i class="fa-solid fa-user-ninja"></i>
<span>Войти как гость (Локальный ИИ)</span>
<!-- 2. СРЕЗЫ И ОТЧЕТЫ -->
<div id="sidebar-view-snapshots" class="sidebar-view w-full space-y-3 hidden">
<!-- ПЕРИОД ВЫБОРКИ СРЕЗОВ -->
<div class="p-2.5 bg-slate-50 rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-slate-600 uppercase flex items-center gap-1.5">
<i class="fa-regular fa-calendar text-indigo-500"></i> Период срезов:
</span>
<button onclick="loadSnapshotsView()" class="text-xs text-indigo-600 hover:text-indigo-800 font-semibold flex items-center gap-1 transition">
<i class="fa-solid fa-rotate-right text-[10px]"></i> Найти
</button>
</div>
</div>
<!-- Модальное окно смены пароля -->
<div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
</h3>
<button type="button" onclick="closeChangePasswordModal()" class="text-slate-400 hover:text-slate-700">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Старый пароль</label>
<input type="password" id="old-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Новый пароль</label>
<input type="password" id="new-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Повторите новый пароль</label>
<input type="password" id="confirm-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div id="pwd-error" class="hidden text-xs text-red-600 bg-red-50 p-2 rounded-lg border border-red-200"></div>
<div id="pwd-success" class="hidden text-xs text-emerald-600 bg-emerald-50 p-2 rounded-lg border border-emerald-200"></div>
<button type="button" onclick="handleChangePassword()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-xl text-xs transition shadow-sm mt-2">
Сохранить новый пароль
</button>
</div>
</div>
</div>
<!-- Модальное окно управления пользователями -->
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]">
<div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление пользователями
</h3>
<button type="button" onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-700">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
<div class="space-y-2 mb-4 bg-slate-50 p-3.5 rounded-xl border border-slate-200 shrink-0">
<p class="text-[11px] font-bold text-slate-700 uppercase">Создать нового пользователя</p>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="new-user-name" placeholder="Логин *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
<input type="password" id="new-user-pwd" placeholder="Пароль *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
</div>
<input type="text" id="new-user-fullname" placeholder="ФИО (необязательно)" class="w-full bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
<div class="flex items-center justify-between pt-1">
<label class="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" id="new-user-is-admin" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Права администратора</span>
</label>
<button type="button" onclick="handleCreateUser()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-1.5 rounded-lg text-xs transition">
+ Добавить
</button>
</div>
<div id="admin-msg" class="hidden text-[11px] text-red-600 pt-1"></div>
</div>
<div class="flex-1 overflow-y-auto space-y-2 pr-1" id="admin-users-list">
<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>
</div>
</div>
</div>
<!-- Хедер -->
<header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
<div class="flex items-center space-x-2.5">
<div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
<i class="fa-solid fa-brain text-lg"></i>
<div>
<label class="text-[10px] text-slate-400 block mb-0.5">С даты:</label>
<input type="date" id="snapshots-date-from" onchange="loadSnapshotsView()"
class="w-full text-xs px-2 py-1.5 bg-white border border-slate-200 rounded-lg text-slate-700 font-mono focus:outline-none focus:border-indigo-500 cursor-pointer">
</div>
<div>
<h1 class="text-sm font-bold text-slate-900 leading-tight">SCUD Orion AI</h1>
<span class="text-[11px] text-slate-500">Task & Context API</span>
<label class="text-[10px] text-slate-400 block mb-0.5">По дату:</label>
<input type="date" id="snapshots-date-to" onchange="loadSnapshotsView()"
class="w-full text-xs px-2 py-1.5 bg-white border border-slate-200 rounded-lg text-slate-700 font-mono focus:outline-none focus:border-indigo-500 cursor-pointer">
</div>
</div>
<div class="flex items-center gap-1.5 pt-1 border-t border-slate-200/60">
<button onclick="setSnapshotDatePreset('today')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
Сегодня
</button>
<button onclick="setSnapshotDatePreset('yesterday')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
Вчера
</button>
<button onclick="setSnapshotDatePreset('days3')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
3 дня
</button>
<button onclick="setSnapshotDatePreset('days7')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
7 дней
</button>
</div>
</div>
<div class="flex items-center space-x-1.5">
<span id="guest-badge" class="hidden text-[10px] text-amber-700 font-semibold bg-amber-50 px-2 py-0.5 rounded-full border border-amber-200">
Гость
</span>
<span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200">
puh
</span>
<button id="admin-users-btn" type="button" onclick="openAdminModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Управление пользователями">
<i class="fa-solid fa-users-gear text-base"></i>
<!-- Панель создания срезов -->
<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="text-[11px] font-bold text-slate-600 uppercase flex items-center gap-1.5">
<i class="fa-regular fa-clock text-indigo-500"></i> Создать срез на дату/время:
</div>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="manual-snapshot-date" placeholder="ДД.ММ.ГГГГ" class="text-xs px-2.5 py-1.5 border border-slate-200 rounded-lg text-center font-mono">
<input type="text" id="manual-snapshot-time" placeholder="ЧЧ:ММ" class="text-xs px-2.5 py-1.5 border border-slate-200 rounded-lg text-center font-mono">
</div>
<button id="btn-create-snapshot" onclick="createSnapshotManual()" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold transition flex items-center justify-center gap-1.5">
<i class="fa-solid fa-camera"></i> Сделать срез
</button>
</div>
<button id="change-pwd-btn" type="button" onclick="openChangePasswordModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Сменить пароль">
<i class="fa-solid fa-key text-base"></i>
<!-- Панель On-Demand генерации отчетов -->
<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="text-[11px] font-bold text-slate-700 uppercase flex items-center gap-1.5">
<i class="fa-solid fa-file-excel text-emerald-600"></i> Сформировать отчет:
</div>
<div class="grid grid-cols-3 gap-1.5">
<button onclick="generateReportDirect('SVODKA')" class="py-2 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-table-list"></i> Сводка
</button>
<button id="tasks-drawer-btn" type="button" onclick="toggleDrawer()" class="bg-indigo-600 active:bg-indigo-700 text-white px-3 py-1.5 rounded-xl text-xs font-semibold flex items-center gap-1.5 shadow-sm">
<i class="fa-solid fa-list-check"></i>
<span>Задачи</span>
<span id="task-count-badge" class="bg-white text-indigo-700 text-[10px] font-bold px-1.5 py-0.2 rounded-full">0</span>
<button onclick="generateReportDirect('SIMPLIFIED')" class="py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-file-lines"></i> Упрощ.
</button>
<button onclick="generateReportDirect('DETAILED')" class="py-2 bg-purple-50 hover:bg-purple-100 text-purple-700 border border-purple-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-chart-column"></i> Детальн.
</button>
</div>
</div>
<button type="button" onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
<i class="fa-solid fa-right-from-bracket text-base"></i>
<div class="flex items-center justify-between pt-1">
<span id="snapshots-count-badge" class="text-xs font-bold text-slate-700">Срезы в базе: 0</span>
<button onclick="loadSnapshotsView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить список">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="snapshots-list" class="space-y-2"></div>
</div>
<!-- 3. РЕЕСТРЫ -->
<div id="sidebar-view-registries" class="sidebar-view w-full space-y-3 hidden">
<div class="grid grid-cols-2 gap-1 p-1 bg-slate-200/60 rounded-xl">
<button data-subtab="exceptions" onclick="switchRegistrySubTab('exceptions')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-user-shield text-[10px]"></i> Исключения
</button>
<button data-subtab="remote" onclick="switchRegistrySubTab('remote')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-house-laptop text-[10px]"></i> Удаленщики
</button>
<button data-subtab="local_trip" onclick="switchRegistrySubTab('local_trip')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-location-dot text-[10px]"></i> Мест. командир.
</button>
<button data-subtab="other" onclick="switchRegistrySubTab('other')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-clipboard-question text-[10px]"></i> Иное
</button>
</div>
<div id="registry-content-container" class="space-y-3"></div>
</div>
<!-- 4. ПРОМПТ -->
<div id="sidebar-view-prompts" class="sidebar-view w-full space-y-3 hidden">
<div class="flex items-center justify-between pb-1 border-b border-slate-200">
<span class="text-xs font-bold text-slate-800">Системный промпт (Ollama)</span>
<button onclick="loadPromptsView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="prompts-content-container" class="space-y-2"></div>
</div>
<!-- 5. КОНТЕКСТ -->
<div id="sidebar-view-context" class="sidebar-view w-full space-y-3 hidden">
<div class="flex items-center justify-between pb-1 border-b border-slate-200">
<span class="text-xs font-bold text-slate-800">Мониторинг сессии</span>
<button onclick="loadContextView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="context-content-container" class="space-y-2"></div>
</div>
</div>
<!-- ПОДВАЛ -->
<div class="p-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
<i class="fa-solid fa-user"></i>
</div>
<div class="min-w-0 flex flex-col">
<span id="user-display-name" class="text-xs font-bold text-slate-800 truncate">Пользователь</span>
<span id="user-display-role" class="text-[10px] text-slate-400">Оператор</span>
</div>
</div>
<div class="flex items-center gap-1">
<button id="admin-panel-btn" type="button" onclick="openAdminModal()" class="hidden p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-slate-200 rounded-lg transition" title="Управление пользователями">
<i class="fa-solid fa-users-gear text-sm"></i>
</button>
<button type="button" onclick="openProfileModal()" class="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200 rounded-lg transition" title="Сменить пароль">
<i class="fa-solid fa-gear text-sm"></i>
</button>
<button type="button" onclick="AuthManager.logout()" class="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition" title="Выйти из системы">
<i class="fa-solid fa-arrow-right-from-bracket text-sm"></i>
</button>
</div>
</div>
</aside>
<!-- ПРАВАЯ ОБЛАСТЬ (ЧАТ И ИИ-АССИСТЕНТ) -->
<main class="flex-1 flex flex-col h-full min-w-0 bg-slate-50 relative">
<header class="h-14 bg-white border-b border-slate-200 px-4 flex items-center justify-between shrink-0 shadow-sm z-10">
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shadow-sm">
<i class="fa-solid fa-robot text-xs"></i>
</div>
<div>
<div class="flex items-center gap-2">
<h1 class="text-sm font-bold text-slate-800">SCUD Orion AI Assistant</h1>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">Online</span>
</div>
<p class="text-[10px] text-slate-400">Система интеллектуального аудита и контроля СКУД / 1С</p>
</div>
</div>
<div class="flex items-center gap-2">
<button onclick="window.openPresenceModal()" class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-bold border border-emerald-200 transition shadow-xs">
<i class="fa-solid fa-building-user text-xs"></i>
<span>Кто в здании</span>
</button>
</div>
</header>
<!-- Главный контейнер -->
<div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden">
<div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
<div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
<div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
<p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
<!-- ЛЕНТА ЧАТА -->
<div id="chat-messages-container" class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-4">
<div class="flex gap-3 max-w-4xl mx-auto w-full">
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-sm"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-sm text-slate-700 leading-relaxed">
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
</div>
</div>
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
</p>
<p class="text-slate-800 text-xs sm:text-sm leading-relaxed">
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
</p>
</div>
</div>
<!-- Превью прикрепленного файла -->
<div id="file-preview-container" class="hidden px-4 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between text-xs text-slate-700">
<div class="flex items-center gap-2 truncate">
<i class="fa-solid fa-paperclip text-indigo-600"></i>
<span id="file-name-display" class="font-medium truncate">file.pdf</span>
<span id="file-size-display" class="text-slate-400 text-[10px]">(0 KB)</span>
</div>
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-red-500 p-1 transition">
<i class="fa-solid fa-xmark text-sm"></i>
<!-- БЕЙДЖ ПРИКРЕПЛЕННОГО ФАЙЛА -->
<div id="file-attachment-preview" class="hidden max-w-4xl mx-auto w-full px-4 pt-2">
<div class="inline-flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-xl text-xs text-indigo-700 shadow-sm">
<i class="fa-solid fa-file text-indigo-600"></i>
<span id="file-attachment-name" class="font-medium truncate max-w-xs"></span>
<button type="button" onclick="clearAttachedFile()" class="text-indigo-400 hover:text-rose-600 ml-1">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
<div class="flex items-center gap-2">
<!-- Скрытый инпут и кнопка прикрепления файла -->
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" accept=".png,.jpg,.jpeg,.pdf,.txt,.csv,.xlsx">
<button type="button" onclick="document.getElementById('file-input').click()" class="text-slate-500 hover:text-indigo-600 p-2 rounded-xl transition" title="Прикрепить файл">
<i class="fa-solid fa-paperclip text-lg"></i>
</button>
<div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
placeholder="Команда, вопрос или перетащите файл сюда..."
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar"></textarea>
</div>
<button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
<span>Отправить</span>
<!-- СТРОКА ВВОДА -->
<div class="px-4 py-3 bg-white border-t border-slate-200">
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-end gap-3 bg-slate-50 border border-slate-300 rounded-2xl px-4 py-2.5 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-indigo-100 shadow-xs">
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1.5 transition shrink-0 mb-0.5" title="Прикрепить файл">
<i class="fa-solid fa-paperclip text-base"></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-[15.5px] text-slate-800 placeholder:text-slate-400 resize-none py-1 leading-6 min-h-[32px] max-h-36" style="height: 32px;"></textarea>
<button type="button" onclick="window.sendMessage()" class="w-8 h-8 rounded-xl bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white flex items-center justify-center shrink-0 shadow-sm transition mb-0.5">
<i class="fa-solid fa-paper-plane text-xs"></i>
</button>
</div>
</div>
</main>
</div>
<!-- Выезжающая панель (Drawer) -->
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
<div class="p-3.5 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0">
<h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
</h2>
<div class="flex items-center gap-3">
<button type="button" onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
<i class="fa-solid fa-rotate-right text-sm"></i>
</button>
<button type="button" onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
<!-- ПОЛНОЭКРАННЫЙ ОВЕРЛЕЙ DRAG & DROP -->
<div id="global-drag-overlay" class="fixed inset-0 bg-indigo-950/70 backdrop-blur-xs z-50 flex items-center justify-center p-8 hidden pointer-events-none transition-all">
<div class="border-3 border-dashed border-indigo-300 rounded-3xl w-full h-full flex flex-col items-center justify-center text-white gap-4 bg-indigo-900/40">
<div class="w-20 h-20 rounded-2xl bg-white/10 flex items-center justify-center shadow-lg backdrop-blur-md border border-white/20">
<i class="fa-solid fa-cloud-arrow-up text-4xl text-indigo-200"></i>
</div>
<div class="text-xl font-bold tracking-wide">Перетащите файл в окно для загрузки</div>
<div class="text-sm text-indigo-200">Поддерживаются PDF (документы и сканы), изображения, Excel, CSV, TXT</div>
</div>
</div>
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
<button type="button" onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button>
<button type="button" onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
<button type="button" onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Бэклог</button>
<button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершено</button>
</div>
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
<div id="modals-container"></div>
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
<div class="text-center text-slate-400 py-8 text-xs">Загрузка ваших задач...</div>
</div>
</aside>
<script src="/static/js/auth.js?v=2.6.0"></script>
<script src="/static/js/tasks.js?v=2.6.0"></script>
<script src="/static/js/manual_absences.js?v=2.6.0"></script>
<script src="/static/js/snapshot_inspector.js?v=2.6.0"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/chat.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/sidebar/core.js?v=2.6.0"></script>
<script src="/static/js/sidebar/registries.js?v=2.6.0"></script>
<script src="/static/js/sidebar/snapshots.js?v=2.6.0"></script>
<script src="/static/js/sidebar/prompts_context.js?v=2.6.0"></script>
<script src="/static/js/presence.js?v=2.6.0"></script>
<script src="/static/js/chat/task_widget.js?v=2.6.0"></script>
<script src="/static/js/chat/core.js?v=2.6.0"></script>
<script src="/static/js/app.js?v=2.6.0"></script>
</body>
</html>
+385 -3
View File
@@ -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,379 @@ 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',
'snapshot_inspector_modal.html',
'presence_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.6.0`);
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.switchTab('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");
const suggBox = document.getElementById("rw-fio-suggestions");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (suggBox) {
suggBox.classList.add("hidden");
suggBox.innerHTML = "";
}
if (modeInput) modeInput.value = mode;
if (mode === 'EDIT') {
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.switchRegistrySubTab('remote');
} 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 rwFioInput = document.getElementById("rw-fio");
if (rwFioInput && typeof setupStaffAutocomplete === "function") {
setupStaffAutocomplete(rwFioInput, "rw-fio-suggestions");
}
//3.1 Автокомплит для местных командировок и иного
const maFioInput = document.getElementById("manual-absence-fio-input");
if (maFioInput && typeof setupStaffAutocomplete === "function") {
setupStaffAutocomplete(maFioInput, "manual-absence-suggestions");
}
// 4. Авто-высота поля ввода команд
const userInputEl = document.getElementById("user-input");
if (userInputEl) {
userInputEl.addEventListener("input", function() {
this.style.height = "24px";
@@ -21,15 +399,19 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// 5. Проверка сессии пользователя
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();
}
+67 -300
View File
@@ -1,308 +1,75 @@
function showAuthModal() {
const el = document.getElementById("auth-modal");
if (el) el.classList.remove("hidden");
}
/**
* ===============================================================================
* FILE: modules/web_api/static/js/auth.js
* ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков.
* ===============================================================================
*/
function hideAuthModal() {
const el = document.getElementById("auth-modal");
if (el) el.classList.add("hidden");
}
const AUTH_STORAGE_KEY = "scud_auth_token";
const USERNAME_STORAGE_KEY = "scud_username";
const FULLNAME_STORAGE_KEY = "scud_full_name";
const IS_ADMIN_STORAGE_KEY = "scud_is_admin";
const USER_ID_STORAGE_KEY = "scud_user_id";
async function handleLogin(e) {
if (e && e.preventDefault) e.preventDefault();
const usernameInput = document.getElementById("auth-username-input");
const passwordInput = document.getElementById("auth-password-input");
const errorEl = document.getElementById("auth-error");
if (!usernameInput || !passwordInput) return;
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username || !password) return;
if (errorEl) errorEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.status === 200) {
// Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
API_TOKEN = data.token;
CURRENT_USERNAME = data.username;
IS_ADMIN = data.is_admin;
IS_GUEST = false;
localStorage.setItem("scud_api_auth_token", data.token);
localStorage.setItem("scud_username", data.username);
localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
localStorage.removeItem("scud_is_guest");
hideAuthModal();
updateUIState();
if (typeof loadTasks === 'function') {
loadTasks();
}
} else {
if (errorEl) {
errorEl.innerText = data.detail || "Ошибка авторизации";
errorEl.classList.remove("hidden");
}
}
} catch (err) {
console.error("[Auth Error]", err);
if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
}
}
}
function enableGuestMode() {
IS_GUEST = true;
API_TOKEN = "";
CURRENT_USERNAME = "Гость";
IS_ADMIN = false;
localStorage.setItem("scud_is_guest", "true");
hideAuthModal();
updateUIState();
}
function logout() {
localStorage.removeItem("scud_api_auth_token");
localStorage.removeItem("scud_username");
localStorage.removeItem("scud_is_admin");
localStorage.removeItem("scud_is_guest");
API_TOKEN = "";
CURRENT_USERNAME = "";
IS_ADMIN = false;
IS_GUEST = false;
showAuthModal();
}
function updateUIState() {
const tasksBtn = document.getElementById("tasks-drawer-btn");
const adminBtn = document.getElementById("admin-users-btn");
const changePwdBtn = document.getElementById("change-pwd-btn");
const guestBadge = document.getElementById("guest-badge");
const usernameBadge = document.getElementById("username-badge");
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
if (tasksBtn) tasksBtn.classList.add("hidden");
if (adminBtn) adminBtn.classList.add("hidden");
if (changePwdBtn) changePwdBtn.classList.add("hidden");
if (guestBadge) guestBadge.classList.remove("hidden");
if (usernameBadge) usernameBadge.classList.add("hidden");
} else {
if (tasksBtn) tasksBtn.classList.remove("hidden");
if (changePwdBtn) changePwdBtn.classList.remove("hidden");
if (guestBadge) guestBadge.classList.add("hidden");
if (usernameBadge) {
usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
usernameBadge.classList.remove("hidden");
}
if (adminBtn) {
const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh");
if (isAdminUser) {
adminBtn.classList.remove("hidden");
} else {
adminBtn.classList.add("hidden");
}
}
}
}
function openChangePasswordModal() {
const el = document.getElementById("change-pwd-modal");
if (el) el.classList.remove("hidden");
}
function closeChangePasswordModal() {
const el = document.getElementById("change-pwd-modal");
if (el) el.classList.add("hidden");
const err = document.getElementById("pwd-error");
const succ = document.getElementById("pwd-success");
if (err) err.classList.add("hidden");
if (succ) succ.classList.add("hidden");
document.getElementById("old-pwd-input").value = "";
document.getElementById("new-pwd-input").value = "";
const confirmInput = document.getElementById("confirm-pwd-input");
if (confirmInput) confirmInput.value = "";
}
async function handleChangePassword(e) {
if (e && e.preventDefault) e.preventDefault();
const old_password = document.getElementById("old-pwd-input").value;
const new_password = document.getElementById("new-pwd-input").value;
const confirmInput = document.getElementById("confirm-pwd-input");
const confirm_password = confirmInput ? confirmInput.value : new_password;
const errorEl = document.getElementById("pwd-error");
const successEl = document.getElementById("pwd-success");
if (errorEl) errorEl.classList.add("hidden");
if (successEl) successEl.classList.add("hidden");
if (new_password !== confirm_password) {
if (errorEl) {
errorEl.innerText = "Новые пароли не совпадают";
errorEl.classList.remove("hidden");
}
return;
}
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
const AuthManager = {
getToken() {
return localStorage.getItem(AUTH_STORAGE_KEY) || "";
},
body: JSON.stringify({ old_password, new_password })
});
const data = await res.json();
if (res.status === 200) {
if (successEl) {
successEl.innerText = "Пароль успешно изменен!";
successEl.classList.remove("hidden");
}
setTimeout(closeChangePasswordModal, 1500);
} else {
if (errorEl) {
errorEl.innerText = data.detail || "Ошибка при смене пароля";
errorEl.classList.remove("hidden");
}
}
} catch (err) {
if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
}
}
}
function openAdminModal() {
const el = document.getElementById("admin-modal");
if (el) el.classList.remove("hidden");
loadUsersList();
}
function closeAdminModal() {
const el = document.getElementById("admin-modal");
if (el) el.classList.add("hidden");
}
async function loadUsersList() {
const listEl = document.getElementById("admin-users-list");
if (!listEl) return;
listEl.innerHTML = '<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>';
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/admin/users", {
headers: { "Authorization": "Bearer " + token }
});
const users = await res.json();
if (res.status === 200) {
listEl.innerHTML = users.map(u => {
const adminTag = u.is_admin ? '<span class="ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-bold">ADMIN</span>' : '<span class="ml-1.5 text-[9px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">USER</span>';
const fullNameHtml = u.full_name ? `<div class="text-[11px] text-slate-500 font-normal">${u.full_name}</div>` : '';
const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—';
const deleteBtn = u.username !== CURRENT_USERNAME ? `<button type="button" onclick="deleteUser(${u.id}, '${u.username}')" class="text-red-500 hover:text-red-700 p-1"><i class="fa-solid fa-trash-can"></i></button>` : '<span class="text-[10px] text-slate-400">Вы</span>';
return `
<div class="flex justify-between items-center bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-xs">
<div>
<div class="flex items-center">
<span class="font-bold text-slate-800">${u.username}</span>
${adminTag}
</div>
${fullNameHtml}
<div class="text-[10px] text-slate-400 mt-0.5">Создан: ${dateStr}</div>
</div>
${deleteBtn}
</div>
`;
}).join("");
} else {
listEl.innerHTML = `<div class="text-xs text-red-500 py-2">${users.detail}</div>`;
}
} catch (err) {
listEl.innerHTML = '<div class="text-xs text-red-500 py-2">Ошибка загрузки пользователей</div>';
}
}
async function handleCreateUser(e) {
if (e && e.preventDefault) e.preventDefault();
const username = document.getElementById("new-user-name").value.trim();
const password = document.getElementById("new-user-pwd").value;
const fullNameInput = document.getElementById("new-user-fullname");
const full_name = fullNameInput ? fullNameInput.value.trim() : "";
const adminCheckbox = document.getElementById("new-user-is-admin");
const is_admin = adminCheckbox ? adminCheckbox.checked : false;
const msgEl = document.getElementById("admin-msg");
if (msgEl) msgEl.classList.add("hidden");
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/admin/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
getUserId() {
const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
return uid ? parseInt(uid, 10) : 1;
},
body: JSON.stringify({ username, password, full_name, is_admin })
getUsername() {
return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
},
getFullName() {
return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
},
isAdmin() {
return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
},
isAuthenticated() {
return Boolean(this.getToken());
},
setSession(token, username, fullName, isAdmin, userId = 1) {
localStorage.setItem(AUTH_STORAGE_KEY, token);
localStorage.setItem(USERNAME_STORAGE_KEY, username);
localStorage.setItem(FULLNAME_STORAGE_KEY, fullName || username);
localStorage.setItem(IS_ADMIN_STORAGE_KEY, String(isAdmin));
localStorage.setItem(USER_ID_STORAGE_KEY, String(userId));
localStorage.setItem("scud_api_auth_token", token);
localStorage.setItem("auth_token", token);
},
getAuthHeaders() {
const token = this.getToken();
const headers = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
},
logout() {
console.log("[Auth] Полный выход из системы...");
localStorage.clear();
sessionStorage.clear();
document.cookie.split(";").forEach((cookie) => {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
});
const data = await res.json();
window.location.href = "/";
}
};
if (res.status === 200) {
document.getElementById("new-user-name").value = "";
document.getElementById("new-user-pwd").value = "";
if (fullNameInput) fullNameInput.value = "";
if (adminCheckbox) adminCheckbox.checked = false;
loadUsersList();
} else {
if (msgEl) {
msgEl.innerText = data.detail || "Ошибка";
msgEl.classList.remove("hidden");
}
}
} catch (err) {
if (msgEl) {
msgEl.innerText = "Ошибка связи с сервером";
msgEl.classList.remove("hidden");
}
}
}
async function deleteUser(userId, username) {
if (!confirm("Удалить пользователя " + username + "?")) return;
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
await fetch("/api/v1/admin/users/" + userId, {
method: "DELETE",
headers: { "Authorization": "Bearer " + token }
});
loadUsersList();
} catch (err) {
alert("Ошибка при удалении");
}
}
window.AuthManager = AuthManager;
window.logout = () => AuthManager.logout();
-321
View File
@@ -1,321 +0,0 @@
// Вспомогательная функция для автоматического изменения высоты текстового поля
function updateInputHeight(el) {
if (!el) return;
el.style.height = "24px";
const newHeight = Math.min(el.scrollHeight, 120);
el.style.height = newHeight + "px";
}
let selectedFile = null;
function handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
if (file.size > 15 * 1024 * 1024) {
alert("Файл слишком большой. Максимальный размер: 15 МБ");
e.target.value = "";
return;
}
selectedFile = file;
const fileNameEl = document.getElementById("file-name-display");
const fileSizeEl = document.getElementById("file-size-display");
const previewContainer = document.getElementById("file-preview-container");
if (fileNameEl) fileNameEl.innerText = file.name;
if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`;
if (previewContainer) previewContainer.classList.remove("hidden");
}
function clearAttachedFile() {
selectedFile = null;
const fileInput = document.getElementById("file-input");
const previewContainer = document.getElementById("file-preview-container");
if (fileInput) fileInput.value = "";
if (previewContainer) previewContainer.classList.add("hidden");
}
// Деактивация всех старых кнопок в истории
function disableAllActionButtons() {
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
allBtnContainers.forEach(container => {
container.querySelectorAll("button").forEach(btn => {
btn.disabled = true;
btn.classList.add("opacity-40", "cursor-not-allowed");
});
});
}
// Быстрая отправка текста кнопки
function handleActionButtonClick(text) {
disableAllActionButtons();
const input = document.getElementById("user-input");
if (input) {
input.value = text;
sendMessage();
}
}
async function sendMessage(e) {
if (e && e.preventDefault) e.preventDefault();
const input = document.getElementById("user-input");
const chatWindow = document.getElementById("chat-window");
const sendBtn = document.getElementById("send-btn");
if (!input || !chatWindow) return;
const text = input.value.trim();
if (!text && !selectedFile) return;
// Деактивируем предыдущие интерактивные кнопки
disableAllActionButtons();
let userDisplayHtml = escapeHtml(text);
if (selectedFile) {
userDisplayHtml = `<div class="font-bold border-b border-indigo-400/40 pb-1 mb-1 text-[11px] flex items-center gap-1.5">
<i class="fa-solid fa-file"></i> ${escapeHtml(selectedFile.name)}
</div>` + userDisplayHtml;
}
const userMsgHtml = `
<div class="flex justify-end mb-3">
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
${userDisplayHtml}
</div>
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
input.value = "";
updateInputHeight(input);
chatWindow.scrollTop = chatWindow.scrollHeight;
if (sendBtn) {
sendBtn.disabled = true;
sendBtn.classList.add("opacity-50");
}
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
const formData = new FormData();
formData.append("session_id", "web_session_main");
formData.append("message", text || "Проанализируй прикрепленный файл");
if (selectedFile instanceof File) {
formData.append("file", selectedFile, selectedFile.name);
}
const headers = {};
if (!isGuest && token) {
headers["Authorization"] = "Bearer " + token;
}
try {
const res = await fetch(endpoint, {
method: "POST",
headers: headers,
body: formData
});
if (res.status === 401 && !isGuest) {
if (typeof logout === 'function') logout();
return;
}
const data = await res.json();
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
const replyText = data.reply || "Пустой ответ от нейросети";
// Генерация блока кнопок подтверждения при необходимости
let actionButtonsHtml = "";
const actionData = data.action_type || data.action;
if (actionData && actionData.buttons && actionData.buttons.length > 0) {
const buttonsMarkup = actionData.buttons.map(btn => {
let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300";
let iconMarkup = '<i class="fa-solid fa-arrow-right text-[10px] opacity-60"></i>';
const labelLower = (btn.label || "").toLowerCase();
const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да");
const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш");
if (isPrimary) {
btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm";
iconMarkup = '<i class="fa-solid fa-check"></i>';
} else if (isDanger) {
btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300";
iconMarkup = '<i class="fa-solid fa-xmark"></i>';
}
return `
<button type="button" onclick="handleActionButtonClick('${escapeHtml(btn.value)}')"
class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
${iconMarkup}
<span>${escapeHtml(btn.label)}</span>
</button>
`;
}).join("");
actionButtonsHtml = `
<div class="action-buttons-container flex flex-wrap items-center gap-2 mt-3 pt-2.5 border-t border-slate-100">
${buttonsMarkup}
</div>
`;
}
const botMsgHtml = `
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
</p>
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p>
${actionButtonsHtml}
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
clearAttachedFile();
if (!isGuest && typeof loadTasks === 'function') {
loadTasks();
}
} catch (err) {
console.error("[Chat Error]", err);
const errorHtml = `
<div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm mb-3">
Ошибка связи с сервером.
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
} finally {
if (sendBtn) {
sendBtn.disabled = false;
sendBtn.classList.remove("opacity-50");
}
}
}
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input");
const dropZone = document.getElementById("chat-window")?.parentElement;
const dropOverlay = document.getElementById("drop-overlay");
if (input) {
let historyIndex = -1;
let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const text = input.value.trim();
if (text) {
if (localHistory.length === 0 || localHistory[0] !== text) {
localHistory.unshift(text);
if (localHistory.length > 50) localHistory.pop();
localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
}
historyIndex = -1;
}
sendMessage(e);
updateInputHeight(input);
return;
}
if (e.key === "ArrowUp") {
const textBeforeCursor = input.value.substring(0, input.selectionStart);
const isFirstLine = !textBeforeCursor.includes("\n");
if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
if (historyIndex < localHistory.length - 1) {
e.preventDefault();
if (historyIndex === -1) {
input.dataset.draft = input.value;
}
historyIndex++;
input.value = localHistory[historyIndex];
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
if (e.key === "ArrowDown") {
const textAfterCursor = input.value.substring(input.selectionEnd);
const isLastLine = !textAfterCursor.includes("\n");
if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
input.value = localHistory[historyIndex];
} else {
historyIndex = -1;
input.value = input.dataset.draft || "";
}
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
});
}
if (dropZone && dropOverlay) {
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
["dragenter", "dragover"].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropOverlay.classList.remove("hidden");
dropOverlay.classList.add("flex");
}, false);
});
["dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
dropOverlay.classList.add("hidden");
dropOverlay.classList.remove("flex");
}
}, false);
});
dropZone.addEventListener("drop", (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length > 0) {
const file = files[0];
handleFileSelect({ target: { files: [file] } });
const fileInput = document.getElementById("file-input");
if (fileInput) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
}
}
}, false);
}
});
+581
View File
@@ -0,0 +1,581 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/chat/core.js
* ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, авто-высота инпута (24px),
* надежный расчет скролла вопроса к верху окна, крупный шрифт text-sm.
* ===============================================================================
*/
let currentAttachedFile = null;
const CHAT_INPUT_STORAGE_KEY = "scud_chat_input_history";
let chatInputHistory = JSON.parse(localStorage.getItem(CHAT_INPUT_STORAGE_KEY) || "[]");
let chatHistoryIndex = -1;
let temporaryCurrentInput = "";
function saveCommandToHistory(commandText) {
if (!commandText || !commandText.trim()) return;
const cleanCmd = commandText.trim();
if (cleanCmd.startsWith("action:save_draft_")) return;
chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd);
chatInputHistory.push(cleanCmd);
if (chatInputHistory.length > 50) chatInputHistory.shift();
localStorage.setItem(CHAT_INPUT_STORAGE_KEY, JSON.stringify(chatInputHistory));
chatHistoryIndex = -1;
}
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: выравнивание вопроса к верхней границе
function scrollToUserMessageTop() {
const container = document.getElementById("chat-messages-container");
if (!container) return;
const userBubbles = container.querySelectorAll(".user-chat-bubble");
const targetEl = userBubbles[userBubbles.length - 1];
if (!targetEl) return;
requestAnimationFrame(() => {
const targetScroll = targetEl.offsetTop - container.offsetTop - 12;
container.scrollTo({
top: Math.max(0, targetScroll),
behavior: 'smooth'
});
});
}
function updateInputHeightAndFade(textarea) {
if (!textarea) return;
if (!textarea.value || textarea.value.trim() === '') {
textarea.style.height = '32px';
textarea.style.overflowY = 'hidden';
textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
return;
}
textarea.style.height = 'auto';
const minHeight = 32;
const maxHeight = 140;
const currentScrollHeight = textarea.scrollHeight;
if (currentScrollHeight <= minHeight + 2) {
textarea.style.height = minHeight + 'px';
textarea.style.overflowY = 'hidden';
textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
} else if (currentScrollHeight > maxHeight) {
textarea.style.height = maxHeight + 'px';
textarea.style.overflowY = 'auto';
textarea.style.maskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
textarea.style.webkitMaskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
} else {
textarea.style.height = currentScrollHeight + 'px';
textarea.style.overflowY = 'hidden';
textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
}
}
function appendUserMessage(text, filename = null) {
const container = document.getElementById("chat-messages-container");
if (!container) return;
const msgId = 'user-msg-' + Date.now();
let fileBadge = '';
if (filename) {
fileBadge = `
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 mb-2 bg-indigo-700/80 rounded-lg text-xs font-semibold text-white shadow-xs">
<i class="fa-solid fa-paperclip text-xs"></i>
<span class="truncate max-w-xs">${escapeHtml(filename)}</span>
</div>
`;
}
const msgHtml = `
<div id="${msgId}" class="user-chat-bubble relative flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
<div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
${fileBadge}
<div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
</div>
<div class="w-8 h-8 rounded-lg bg-slate-200 text-slate-600 flex items-center justify-center shrink-0 shadow-sm mt-0.5 font-bold text-sm">
<i class="fa-solid fa-user"></i>
</div>
</div>
`;
container.insertAdjacentHTML("beforeend", msgHtml);
}
function appendAssistantLoading() {
const container = document.getElementById("chat-messages-container");
if (!container) return null;
const loadingId = 'loading-' + Date.now();
const html = `
<div id="${loadingId}" class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-sm"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-sm text-slate-500 flex items-center gap-2">
<i class="fa-solid fa-spinner fa-spin text-indigo-600"></i>
<span>ИИ обрабатывает запрос...</span>
</div>
</div>
</div>
`;
container.insertAdjacentHTML("beforeend", html);
return loadingId;
}
function appendAssistantMessage(text, buttons = [], actionPayload = null) {
const container = document.getElementById("chat-messages-container");
if (!container) return;
let payloadHtml = '';
let isHtmlBody = false;
if (actionPayload) {
if (actionPayload.type === 'SNAPSHOTS_CARD' && typeof renderSnapshotsCard === 'function') {
payloadHtml = renderSnapshotsCard(actionPayload.data);
} else if (actionPayload.type === 'TASK_INTERACTIVE_CARD' && typeof renderInteractiveTaskCard === 'function') {
payloadHtml = renderInteractiveTaskCard(actionPayload.tasks);
} else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') {
const dlUrl = actionPayload.download_url || '#';
const fName = actionPayload.filename || 'document.docx';
const count = actionPayload.tasks_count || '';
payloadHtml = `
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-200 rounded-xl flex items-center justify-between gap-3 shadow-xs">
<div class="flex items-center gap-3 min-w-0">
<div class="w-10 h-10 rounded-xl bg-blue-600 text-white flex items-center justify-center shrink-0 shadow-sm">
<i class="fa-solid fa-file-word text-lg"></i>
</div>
<div class="min-w-0">
<div class="text-sm font-bold text-slate-800 truncate">${escapeHtml(fName)}</div>
<div class="text-xs text-slate-500">${count ? count + ' · ' : ''}Документ MS Word (.docx)</div>
</div>
</div>
<a href="${dlUrl}" download="${escapeHtml(fName)}" target="_blank"
class="px-4 py-2.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-bold rounded-xl text-xs shadow-sm transition flex items-center gap-2 shrink-0">
<i class="fa-solid fa-arrow-down-to-line text-sm text-white"></i>
<span class="text-white tracking-wide">Скачать файл</span>
</a>
</div>
`;
} else if (actionPayload.type === 'SNAPSHOT_INSPECT_CARD') {
const records = actionPayload.records || [];
const inspectTableId = 'inspect-table-' + Date.now();
const searchInputId = 'inspect-search-' + Date.now();
const rowsHtml = records.map((r, idx) => `
<tr class="inspect-row border-b border-slate-100 text-xs ${r.is_present ? 'bg-white' : 'bg-slate-50/60'} hover:bg-indigo-50/40"
data-fio="${escapeHtml(r.fio).toLowerCase()}" data-dept="${escapeHtml(r.department).toLowerCase()}">
<td class="p-2.5 text-slate-400 text-center font-mono w-10">${idx + 1}</td>
<td class="p-2.5 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
<td class="p-2.5 text-slate-500 text-center">${escapeHtml(r.department)}</td>
<td class="p-2.5 text-center ${r.time_in !== 'Нет входа' ? 'font-bold text-emerald-700' : 'text-slate-400'}">${escapeHtml(r.time_in)}</td>
<td class="p-2.5 text-center text-slate-500">${escapeHtml(r.first_activity)}</td>
<td class="p-2.5 text-center ${r.time_out !== 'Нет выхода' ? 'font-bold text-slate-800' : 'text-slate-400'}">${escapeHtml(r.time_out)}</td>
<td class="p-2.5 text-center font-mono text-slate-600">${escapeHtml(r.in_building)}</td>
<td class="p-2.5 text-center font-bold">${r.is_present ? '<span class="text-emerald-600">✓ Да</span>' : '<span class="text-slate-400">Нет</span>'}</td>
</tr>
`).join('');
payloadHtml = `
<div class="mt-3 bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm flex flex-col">
<div class="px-4 py-3 bg-slate-100/90 border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-800">Срез #${escapeHtml(actionPayload.snapshot_id)}</span>
<span class="text-xs text-slate-500">· Всего: ${records.length} чел. (Присутствуют: ${actionPayload.present_count || 0})</span>
</div>
<div class="flex items-center gap-2">
<input type="text" id="${searchInputId}" placeholder="Поиск в срезе (ФИО / отдел)..."
oninput="window.filterInspectTable('${inspectTableId}', this.value)"
class="text-xs px-3 py-1.5 bg-white border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 w-60" />
<button onclick="window.sendChatAction('покажи срезы')" class="text-xs text-indigo-600 hover:underline font-semibold">Все срезы</button>
</div>
</div>
<div class="max-h-96 overflow-y-auto">
<table id="${inspectTableId}" class="w-full text-left border-collapse">
<thead class="bg-slate-50 text-[11px] uppercase text-slate-500 sticky top-0 border-b border-slate-200 shadow-xs">
<tr>
<th class="p-2.5 text-center w-10">№</th>
<th class="p-2.5">Сотрудник</th>
<th class="p-2.5 text-center">Отдел</th>
<th class="p-2.5 text-center">Вход</th>
<th class="p-2.5 text-center">Активность</th>
<th class="p-2.5 text-center">Выход</th>
<th class="p-2.5 text-center">В здании</th>
<th class="p-2.5 text-center">Статус</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>
</div>
`;
} else if (actionPayload.type === 'PROMPT_EDITOR') {
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
const editorId = 'prompt-editor-' + Date.now();
payloadHtml = `
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
<span><i class="fa-solid fa-pen-to-square text-indigo-600 mr-1"></i> Инлайн-редактор системного промпта:</span>
<span class="text-[11px] text-slate-400 font-normal">Прямое редактирование текста</span>
</div>
<textarea id="${editorId}" rows="14"
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
<button type="button" onclick="window.submitPromptDraftToDiff('${editorId}')" 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-eye text-xs"></i>
<span>Показать превью изменений</span>
</button>
</div>
</div>
`;
} else if (actionPayload.type === 'RULES_EDITOR') {
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
const editorId = 'rules-editor-' + Date.now();
payloadHtml = `
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
<span><i class="fa-solid fa-book-bookmark text-emerald-600 mr-1"></i> Редактор базы знаний и правил компании:</span>
<span class="text-[11px] text-slate-400 font-normal">Прямое изменение правил кадрового арбитража</span>
</div>
<textarea id="${editorId}" rows="12"
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
<button type="button" onclick="window.submitRulesDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
<i class="fa-solid fa-eye text-xs"></i>
<span>Показать превью изменений</span>
</button>
</div>
</div>
`;
} else if (actionPayload.type === 'PROMPT_PREVIEW') {
isHtmlBody = true;
}
}
let buttonsHtml = '';
if (!actionPayload || (actionPayload.type !== 'PROMPT_EDITOR' && actionPayload.type !== 'RULES_EDITOR')) {
const allButtons = (buttons && buttons.length > 0) ? buttons : (actionPayload && actionPayload.buttons ? actionPayload.buttons : []);
if (allButtons && Array.isArray(allButtons) && allButtons.length > 0) {
buttonsHtml = `
<div class="flex flex-wrap gap-2 mt-3.5 pt-2.5 border-t border-slate-100">
${allButtons.map(b => `
<button onclick="window.sendChatAction('${escapeHtml(b.value || b.action || b.title || '')}')"
class="px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold border border-indigo-200 transition">
${escapeHtml(b.label || b.title || b.action)}
</button>
`).join('')}
</div>
`;
}
}
const bodyContent = isHtmlBody ? text : escapeHtml(text);
const html = `
<div class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-sm"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm min-w-0">
<div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1.5">ИИ-ассистент SCUD Orion AI</div>
<div class="text-sm text-slate-800 leading-relaxed whitespace-pre-wrap">${bodyContent}</div>
${payloadHtml}
${buttonsHtml}
</div>
</div>
`;
container.insertAdjacentHTML("beforeend", html);
}
window.filterInspectTable = function(tableId, query) {
const table = document.getElementById(tableId);
if (!table) return;
const q = (query || '').trim().toLowerCase();
const rows = table.querySelectorAll('.inspect-row');
rows.forEach(r => {
const fio = r.getAttribute('data-fio') || '';
const dept = r.getAttribute('data-dept') || '';
if (!q || fio.includes(q) || dept.includes(q)) {
r.classList.remove('hidden');
} else {
r.classList.add('hidden');
}
});
};
window.sendChatAction = function(actionText) {
if (!actionText || !actionText.trim()) return;
const input = document.getElementById("user-input");
if (input) {
input.value = actionText.trim();
updateInputHeightAndFade(input);
}
window.sendMessage();
};
window.submitPromptDraftToDiff = function(editorId) {
const textarea = document.getElementById(editorId);
if (!textarea) return;
const newText = textarea.value.trim();
if (!newText) {
alert("Текст системного промпта не может быть пустым");
return;
}
const command = `action:save_draft_prompt:::${newText}`;
const input = document.getElementById("user-input");
if (input) input.value = command;
window.sendMessage();
};
window.submitRulesDraftToDiff = function(editorId) {
const textarea = document.getElementById(editorId);
if (!textarea) return;
const newText = textarea.value.trim();
if (!newText) {
alert("Правила не могут быть пустыми");
return;
}
const command = `action:save_draft_rules:::${newText}`;
const input = document.getElementById("user-input");
if (input) input.value = command;
window.sendMessage();
};
window.sendMessage = async function() {
const input = document.getElementById("user-input");
if (!input) return;
const messageText = input.value.trim();
const fileToSend = currentAttachedFile;
if (!messageText && !fileToSend) return;
saveCommandToHistory(messageText);
input.value = "";
input.style.height = '32px';
input.style.overflowY = 'hidden';
input.style.maskImage = 'none';
input.style.webkitMaskImage = 'none';
window.clearAttachedFile();
// 1. Отрисовка сообщения пользователя в ленте
if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) {
appendUserMessage(
messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''),
fileToSend ? fileToSend.name : null
);
} else {
appendUserMessage("Сформировать предпросмотр изменений");
}
// 2. Вставка лоадера
const loadingId = appendAssistantLoading();
// 3. Вызов точного скролла
scrollToUserMessageTop();
try {
let res;
if (fileToSend) {
const formData = new FormData();
formData.append("file", fileToSend);
formData.append("message", messageText);
formData.append("session_id", "web_session_main");
res = await fetch("/api/v1/chat/upload", {
method: "POST",
headers: {
"Authorization": AuthManager.getAuthHeaders()["Authorization"]
},
body: formData
});
if (res.status === 404) {
res = await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: messageText || `Загружен файл: ${fileToSend.name}`,
session_id: "web_session_main"
})
});
}
} else {
res = await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: messageText,
session_id: "web_session_main"
})
});
}
const loaderEl = document.getElementById(loadingId);
if (loaderEl) loaderEl.remove();
if (res.ok) {
const data = await res.json();
const replyText = data.response || data.text || data.message || "Запрос выполнен.";
const buttons = data.buttons || [];
const actionPayload = data.action_payload || null;
appendAssistantMessage(replyText, buttons, actionPayload);
if (window.SidebarManager) {
SidebarManager.renderContent();
}
} else {
const err = await res.json().catch(() => ({}));
appendAssistantMessage(`⚠️ Ошибка сервера (${res.status}): ${err.detail || "Не удалось получить ответ"}`);
}
} catch (err) {
console.error("Ошибка отправки сообщения:", err);
const loaderEl = document.getElementById(loadingId);
if (loaderEl) loaderEl.remove();
appendAssistantMessage("⚠️ Ошибка соединения с сервером при отправке сообщения.");
}
};
window.clearAttachedFile = function() {
currentAttachedFile = null;
const preview = document.getElementById("file-attachment-preview");
const nameEl = document.getElementById("file-attachment-name");
const fileInput = document.getElementById("file-upload-input");
if (preview) preview.classList.add("hidden");
if (nameEl) nameEl.innerText = "";
if (fileInput) fileInput.value = "";
};
function handleFileSelected(file) {
if (!file) return;
currentAttachedFile = file;
const preview = document.getElementById("file-attachment-preview");
const nameEl = document.getElementById("file-attachment-name");
if (preview && nameEl) {
nameEl.innerText = file.name;
preview.classList.remove("hidden");
}
}
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input");
const fileInput = document.getElementById("file-upload-input");
const overlay = document.getElementById("global-drag-overlay");
if (input) {
input.style.lineHeight = '24px';
input.style.height = '32px';
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
if (e.shiftKey) return;
e.preventDefault();
window.sendMessage();
return;
}
if (e.key === "ArrowUp") {
const isSingleLine = !input.value.includes("\n");
const isAtBeginning = input.selectionStart === 0 && input.selectionEnd === 0;
if (isSingleLine || isAtBeginning) {
if (chatInputHistory.length > 0) {
e.preventDefault();
if (chatHistoryIndex === -1) {
temporaryCurrentInput = input.value;
chatHistoryIndex = chatInputHistory.length - 1;
} else if (chatHistoryIndex > 0) {
chatHistoryIndex--;
}
input.value = chatInputHistory[chatHistoryIndex];
updateInputHeightAndFade(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
} else if (e.key === "ArrowDown") {
const isSingleLine = !input.value.includes("\n");
const isAtEnd = input.selectionStart === input.value.length;
if (isSingleLine || isAtEnd) {
if (chatHistoryIndex !== -1) {
e.preventDefault();
if (chatHistoryIndex < chatInputHistory.length - 1) {
chatHistoryIndex++;
input.value = chatInputHistory[chatHistoryIndex];
} else {
chatHistoryIndex = -1;
input.value = temporaryCurrentInput;
}
updateInputHeightAndFade(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
});
input.addEventListener("input", function() {
updateInputHeightAndFade(this);
chatHistoryIndex = -1;
});
}
if (fileInput) {
fileInput.addEventListener("change", (e) => {
if (e.target.files && e.target.files[0]) {
handleFileSelected(e.target.files[0]);
}
});
}
let dragCounter = 0;
window.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
if (overlay) {
overlay.classList.remove('hidden');
}
}, false);
window.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
if (overlay) {
overlay.classList.add('hidden');
}
}
}, false);
window.addEventListener('dragover', (e) => {
e.preventDefault();
}, false);
window.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
if (overlay) {
overlay.classList.add('hidden');
}
const dt = e.dataTransfer;
if (dt && dt.files && dt.files[0]) {
handleFileSelected(dt.files[0]);
}
}, false);
});
@@ -0,0 +1,445 @@
/*
===============================================================================
FILE: modules/web_api/static/js/chat/task_widget.js
ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами.
===============================================================================
*/
window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS';
window.currentTasksCache = window.currentTasksCache || [];
function getAuthHeaders() {
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
return headers;
}
window.sendChatAction = function(actionText) {
const input = document.getElementById("user-input");
if (input && typeof sendMessage === "function") {
input.value = actionText;
if (typeof setInputLocked === "function") setInputLocked(false);
sendMessage();
}
};
// ============================================================================
// РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ
// ============================================================================
function renderSnapshotsCard(data) {
if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return '';
const queryDate = data.query_date || 'выбранную дату';
const snapshots = data.snapshots;
if (snapshots.length === 0) {
return `
<div class="p-6 bg-slate-50 border border-slate-200 rounded-xl text-center text-xs text-slate-500 my-2">
📸 За дату <b>${queryDate}</b> сохраненных снапшотов не найдено.
</div>
`;
}
const rowsHtml = snapshots.map((s, idx) => {
const snapId = s.snapshot_id || `ID-${idx}`;
const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—';
const count = s.record_count || 0;
const isFinal = snapId.startsWith('Y');
if (isFinal) {
return `
<div class="p-2.5 bg-purple-50/60 rounded-lg border border-purple-200 shadow-sm flex items-center justify-between gap-3">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-4 flex justify-center text-purple-400" title="Итоговый срез защищен">
<i class="fa-solid fa-lock text-[11px]"></i>
</span>
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-purple-100 text-purple-800 border border-purple-300">
#${snapId}
</span>
<div class="flex items-center gap-3 text-xs text-slate-600">
<span class="flex items-center gap-1 font-semibold text-purple-900">
<i class="fa-regular fa-clock text-purple-600 text-[11px]"></i> ${snapTime}
</span>
<span class="flex items-center gap-1 text-slate-500">
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
</span>
</div>
</div>
<span class="text-[10px] font-bold text-purple-700 bg-purple-100 border border-purple-200 px-2 py-0.5 rounded">
Итоговый Y-срез
</span>
</div>
`;
}
return `
<div class="p-2.5 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition flex items-center justify-between gap-3">
<div class="flex items-center gap-2.5 min-w-0">
<input type="checkbox" value="${snapId}" onchange="window.updateSelectedSnapshots(this)"
class="snapshot-item-checkbox rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-4 h-4 cursor-pointer">
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">
#${snapId}
</span>
<div class="flex items-center gap-3 text-xs text-slate-600">
<span class="flex items-center gap-1 font-semibold text-slate-800">
<i class="fa-regular fa-clock text-indigo-500 text-[11px]"></i> ${snapTime}
</span>
<span class="flex items-center gap-1 text-slate-500">
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
</span>
</div>
</div>
<button type="button" onclick="window.sendChatAction('удали снапшот ${snapId}')"
class="p-1 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-transparent hover:border-rose-200 transition"
title="Удалить срез">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
`;
}).join('');
return `
<div class="snapshots-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
<div class="px-4 py-2.5 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<div class="bg-indigo-600 text-white p-1.5 rounded-lg flex items-center justify-center">
<i class="fa-solid fa-camera text-xs"></i>
</div>
<div>
<span class="text-xs font-bold text-slate-800">Реестр срезов СКУД</span>
<span class="text-xs text-slate-500 ml-1">за ${queryDate}</span>
</div>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer select-none">
<input type="checkbox" onchange="window.toggleSelectAllSnapshots(this)" class="select-all-snapshots-cb rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-3.5 h-3.5">
<span>Выбрать все</span>
</label>
<span class="text-[11px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full">
Срезов: ${snapshots.length}
</span>
</div>
</div>
<div class="p-3 flex flex-col gap-2">
${rowsHtml}
</div>
<!-- ПОДВАЛ С КНОПКОЙ УДАЛЕНИЯ ВЫБРАННЫХ -->
<div class="snapshot-bulk-actions-footer hidden px-4 py-2 bg-rose-50/70 border-t border-rose-200 flex items-center justify-between">
<span class="text-xs text-rose-800 font-medium bulk-selected-counter">Выбрано: 0</span>
<button type="button" onclick="window.submitBulkDeleteSnapshots(this)"
class="px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-sm">
<i class="fa-solid fa-trash-can"></i>
<span>Удалить выбранные</span>
</button>
</div>
</div>
`;
}
// ⭐️ Обработчики чекбоксов
window.toggleSelectAllSnapshots = function(masterCb) {
const root = masterCb.closest('.snapshots-widget-root');
if (!root) return;
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox');
checkboxes.forEach(cb => cb.checked = masterCb.checked);
window.syncBulkDeleteFooter(root);
};
window.updateSelectedSnapshots = function(itemCb) {
const root = itemCb.closest('.snapshots-widget-root');
if (!root) return;
window.syncBulkDeleteFooter(root);
};
window.syncBulkDeleteFooter = function(root) {
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked');
const footer = root.querySelector('.snapshot-bulk-actions-footer');
const counter = root.querySelector('.bulk-selected-counter');
const masterCb = root.querySelector('.select-all-snapshots-cb');
const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox');
if (masterCb) {
masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
}
if (checkboxes.length > 0) {
if (footer) footer.classList.remove('hidden');
if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`;
} else {
if (footer) footer.classList.add('hidden');
}
};
// Одиночная корзина в строке снапшота:
// onclick="window.sendChatAction('удали снапшот ${snapId}')"
// Кнопка пакетного удаления в подвале карточки:
window.submitBulkDeleteSnapshots = function(btnEl) {
const root = btnEl.closest('.snapshots-widget-root');
if (!root) return;
const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return;
window.sendChatAction(`удали снапшоты ${selected.join(', ')}`);
};
// ============================================================================
// РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD)
// ============================================================================
function renderInteractiveTaskCard(tasks) {
if (!tasks || !Array.isArray(tasks)) return '';
window.currentTasksCache = tasks;
const counts = {
ALL: tasks.length,
IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length,
PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length,
COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length
};
const currentFilter = window.activeTaskFilter || 'IN_PROGRESS';
const filteredTasks = tasks.filter(t => {
const s = (t.status || 'BACKLOG').toUpperCase();
if (currentFilter === 'ALL') return true;
if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS';
if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED';
if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE';
return true;
});
const getPrioBadge = (prio) => {
const p = (prio || 'MEDIUM').toUpperCase();
if (p === 'HIGH' || p === 'CRITICAL') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-rose-100 text-rose-700 border border-rose-200">🔥 HIGH</span>';
if (p === 'LOW') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-100 text-slate-600 border border-slate-200">☕ LOW</span>';
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-amber-100 text-amber-700 border border-amber-200">⚡ MEDIUM</span>';
};
const getStatusBadge = (status) => {
const s = (status || 'BACKLOG').toUpperCase();
if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-blue-50 text-blue-700 border border-blue-200">⚙️ В работе</span>';
if (s === 'COMPLETED' || s === 'DONE') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">✓ Готово</span>';
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-50 text-slate-600 border border-slate-200">📋 В планах</span>';
};
const taskRows = filteredTasks.map(t => {
const id = t.id;
const title = t.title || 'Без названия';
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS';
const actionBtn = isInProgress
? `<button type="button" onclick="window.sendChatAction('заверши задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-emerald-50 text-emerald-700 border border-emerald-300 hover:bg-emerald-100 transition-colors shadow-sm" title="Завершить задачу">✓ Готово</button>`
: (!isDone
? `<button type="button" onclick="window.sendChatAction('возьми в работу задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-blue-50 text-blue-700 border border-blue-300 hover:bg-blue-100 transition-colors shadow-sm" title="Взять в работу">⚙️ В работу</button>`
: '');
return `
<div id="task-card-${id}" class="p-3 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition-all flex flex-col gap-2">
<div class="task-view-mode flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">#${id}</span>
<span class="text-sm font-medium text-slate-900 truncate" title="${title}">${title}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
${actionBtn}
<button type="button" onclick="window.openTaskInlineEditor(${id})" class="p-1 text-xs text-slate-500 hover:text-indigo-600 hover:bg-slate-50 rounded border border-slate-200 transition-colors" title="Редактировать">✏️</button>
<button type="button" onclick="window.sendChatAction('удали задачу ${id}')" class="p-1 text-xs text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-slate-200 transition-colors" title="Удалить">🗑️</button>
</div>
</div>
<div class="task-view-mode flex items-center gap-2 text-xs text-slate-500 flex-wrap">
${getPrioBadge(t.priority)}
${getStatusBadge(t.status)}
<span class="px-1.5 py-0.5 rounded bg-slate-50 border border-slate-200 text-slate-600 font-mono text-[11px]">${t.module || 'general'}</span>
${t.due_date ? `<span class="text-slate-500">📅 срок: <b>${t.due_date}</b></span>` : ''}
${t.created_at ? `<span class="text-slate-400">создана: ${t.created_at.split(' ')[0]}</span>` : ''}
</div>
<!-- ФОРМА ИНЛАЙН РЕДАКТИРОВАНИЯ -->
<div id="task-editor-${id}" class="hidden flex flex-col gap-2 pt-2 border-t border-slate-100">
<input type="text" id="task-edit-title-${id}" value="${title.replace(/"/g, '&quot;')}" class="w-full text-xs px-2.5 py-1.5 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" placeholder="Описание задачи..." />
<div class="flex items-center gap-2 flex-wrap">
<input type="date" id="task-edit-date-${id}" value="${t.due_date || ''}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" />
<select id="task-edit-prio-${id}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50">
<option value="LOW" ${t.priority === 'LOW' ? 'selected' : ''}>☕ LOW</option>
<option value="MEDIUM" ${t.priority === 'MEDIUM' || !t.priority ? 'selected' : ''}>⚡ MEDIUM</option>
<option value="HIGH" ${t.priority === 'HIGH' ? 'selected' : ''}>🔥 HIGH</option>
<option value="CRITICAL" ${t.priority === 'CRITICAL' ? 'selected' : ''}>🚨 CRITICAL</option>
</select>
<button type="button" onclick="window.saveTaskInlineEdit(${id})" class="px-2.5 py-1 text-xs font-semibold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Сохранить</button>
<button type="button" onclick="window.closeTaskInlineEditor(${id})" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-100 transition-colors">Отмена</button>
</div>
</div>
</div>
`;
}).join('');
const emptyState = `
<div class="p-8 text-center text-slate-400">
<div class="text-3xl mb-2">📭</div>
<div class="text-sm font-medium">Нет задач в категории «${currentFilter}»</div>
</div>
`;
return `
<div class="task-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
<div class="px-4 py-3 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-1.5 flex-wrap">
<button type="button" onclick="window.switchTaskFilter('IN_PROGRESS', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'IN_PROGRESS' ? 'bg-blue-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
⚙️ В работе (${counts.IN_PROGRESS})
</button>
<button type="button" onclick="window.switchTaskFilter('PLANNED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'PLANNED' ? 'bg-indigo-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
📋 В планах (${counts.PLANNED})
</button>
<button type="button" onclick="window.switchTaskFilter('COMPLETED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'COMPLETED' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
✓ Готово (${counts.COMPLETED})
</button>
<button type="button" onclick="window.switchTaskFilter('ALL', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'ALL' ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
Все (${counts.ALL})
</button>
</div>
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-3 py-1.5 text-xs font-bold rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-all shadow-sm flex items-center gap-1">
➕ Добавить задачу
</button>
</div>
<div class="new-task-creation-form hidden p-3 bg-indigo-50/70 border-b border-indigo-100 flex flex-col gap-2">
<div class="text-xs font-bold text-indigo-900">Новая задача:</div>
<input type="text" class="new-task-title w-full text-xs px-2.5 py-1.5 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" placeholder="Что необходимо сделать?..." />
<div class="flex items-center gap-2 flex-wrap">
<input type="date" class="new-task-date text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" />
<select class="new-task-prio text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
<option value="LOW">☕ LOW (Низкий)</option>
<option value="MEDIUM" selected>⚡ MEDIUM (Средний)</option>
<option value="HIGH">🔥 HIGH (Высокий)</option>
<option value="CRITICAL">🚨 CRITICAL (Критический)</option>
</select>
<select class="new-task-status text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
<option value="BACKLOG" selected>📋 В планы (Бэклог)</option>
<option value="IN_PROGRESS">⚙️ Сразу в работу</option>
</select>
<button type="button" onclick="window.submitCreateTask(this)" class="px-3 py-1 text-xs font-bold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Создать</button>
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-200 transition-colors">Отмена</button>
</div>
</div>
<div class="p-3 overflow-y-auto max-h-[70vh] flex flex-col gap-2 task-rows-container">
${filteredTasks.length > 0 ? taskRows : emptyState}
</div>
</div>
`;
}
window.switchTaskFilter = function(filterName, btnEl) {
window.activeTaskFilter = filterName;
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
if (root && window.currentTasksCache && window.currentTasksCache.length > 0) {
root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache);
}
};
window.toggleCreateTaskForm = function(btnEl) {
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
if (!root) return;
const form = root.querySelector('.new-task-creation-form');
if (form) {
form.classList.toggle('hidden');
if (!form.classList.contains('hidden')) {
const input = form.querySelector('.new-task-title');
if (input) input.focus();
}
}
};
window.submitCreateTask = async function(btnEl) {
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
if (!root) return;
const titleInput = root.querySelector('.new-task-title');
const dateInput = root.querySelector('.new-task-date');
const prioInput = root.querySelector('.new-task-prio');
const statusInput = root.querySelector('.new-task-status');
if (!titleInput || !titleInput.value.trim()) {
alert('Введите описание задачи');
return;
}
try {
const res = await fetch('/api/v1/tasks', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
title: titleInput.value.trim(),
due_date: dateInput ? (dateInput.value || null) : null,
priority: prioInput ? prioInput.value : 'MEDIUM',
status: statusInput ? statusInput.value : 'BACKLOG'
})
});
if (res.ok) {
window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED';
window.sendChatAction('покажи задачи');
} else {
const err = await res.json();
alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка'));
}
} catch (e) {
console.error('Ошибка создания задачи:', e);
alert('Сетевая ошибка при создании задачи');
}
};
window.openTaskInlineEditor = function(id) {
const card = document.getElementById(`task-card-${id}`);
if (!card) return;
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden'));
const editor = document.getElementById(`task-editor-${id}`);
if (editor) editor.classList.remove('hidden');
};
window.closeTaskInlineEditor = function(id) {
const card = document.getElementById(`task-card-${id}`);
if (!card) return;
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden'));
const editor = document.getElementById(`task-editor-${id}`);
if (editor) editor.classList.add('hidden');
};
window.saveTaskInlineEdit = async function(id) {
const titleInput = document.getElementById(`task-edit-title-${id}`);
const dateInput = document.getElementById(`task-edit-date-${id}`);
const prioInput = document.getElementById(`task-edit-prio-${id}`);
if (!titleInput || !titleInput.value.trim()) {
alert('Описание задачи не может быть пустым');
return;
}
try {
const res = await fetch(`/api/v1/tasks/${id}`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify({
title: titleInput.value.trim(),
due_date: dateInput ? (dateInput.value || null) : null,
priority: prioInput ? prioInput.value : 'MEDIUM'
})
});
if (res.ok) {
window.sendChatAction('покажи задачи');
} else {
const err = await res.json();
alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка'));
}
} catch (e) {
console.error('Ошибка сохранения задачи:', e);
alert('Сетевая ошибка при обновлении задачи');
}
};
@@ -0,0 +1,215 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/manual_absences.js
* ROLE: Модальные окна "Мест. командир.", "Иное", универсальный автокомплит ФИО
* и синхронизация с боковой панелью SidebarManager.
* ===============================================================================
*/
let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER'
let reasonsCache = [];
async function loadAbsenceReasons() {
try {
const res = await fetch('/api/v1/manual-absences/reasons');
if (res.ok) {
const data = await res.json();
reasonsCache = data.reasons || [];
}
} catch (e) {
console.error('Ошибка загрузки причин:', e);
}
}
// ⭐️ Открытие окна: режим создания (item = null) или редактирования (item = {...})
function openManualAbsenceModal(type, editItem = null) {
activeAbsenceType = type;
const isTrip = (type === 'LOCAL_TRIP');
const modal = document.getElementById('manual-absence-modal');
const titleEl = document.getElementById('manual-absence-modal-title-text');
const reasonBlock = document.getElementById('manual-absence-reason-block');
const reasonSelect = document.getElementById('manual-absence-reason-select');
const idInput = document.getElementById('manual-absence-id');
const fioInput = document.getElementById('manual-absence-fio-input');
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
const startDateInput = document.getElementById('manual-absence-start-date');
const endDateInput = document.getElementById('manual-absence-end-date');
const errEl = document.getElementById('manual-absence-error');
if (errEl) errEl.classList.add('hidden');
if (reasonBlock && reasonSelect) {
if (isTrip) {
reasonBlock.classList.add('hidden');
} else {
reasonBlock.classList.remove('hidden');
reasonSelect.innerHTML = reasonsCache.map(r => `<option value="${r}">${r}</option>`).join('');
}
}
const today = new Date().toISOString().split('T')[0];
if (editItem) {
// Режим РЕДАКТИРОВАНИЯ
if (titleEl) titleEl.innerText = isTrip ? 'Изменение сроков командировки' : 'Изменение сроков отсутствия';
if (idInput) idInput.value = editItem.id;
if (fioInput) {
fioInput.value = editItem.fio;
fioInput.readOnly = true;
fioInput.classList.add('bg-slate-100', 'text-slate-500', 'cursor-not-allowed');
}
if (deptInput) deptInput.value = editItem.department || '';
if (posInput) posInput.value = editItem.position || '';
if (startDateInput) startDateInput.value = editItem.date_start ? editItem.date_start.replace(/\./g, '-') : today;
if (endDateInput) endDateInput.value = editItem.date_end ? editItem.date_end.replace(/\./g, '-') : today;
if (reasonSelect && editItem.reason) reasonSelect.value = editItem.reason;
} else {
// Режим СОЗДАНИЯ
if (titleEl) titleEl.innerText = isTrip ? 'Добавить в командировки' : 'Добавить отсутствие';
if (idInput) idInput.value = '';
if (fioInput) {
fioInput.value = '';
fioInput.readOnly = false;
fioInput.classList.remove('bg-slate-100', 'text-slate-500', 'cursor-not-allowed');
}
if (deptInput) deptInput.value = '';
if (posInput) posInput.value = '';
if (startDateInput) startDateInput.value = today;
if (endDateInput) endDateInput.value = today;
}
if (modal) modal.classList.remove('hidden');
}
function closeManualAbsenceModal() {
const modal = document.getElementById('manual-absence-modal');
if (modal) modal.classList.add('hidden');
}
async function submitManualAbsence(event) {
if (event) event.preventDefault();
const idVal = document.getElementById('manual-absence-id')?.value.trim();
const fio = document.getElementById('manual-absence-fio-input')?.value.trim();
const deptVal = document.getElementById('manual-absence-dept')?.value.trim() || '';
const posVal = document.getElementById('manual-absence-pos')?.value.trim() || '';
const startDateVal = document.getElementById('manual-absence-start-date')?.value || null;
const endDateVal = document.getElementById('manual-absence-end-date')?.value || null;
const reasonSelect = document.getElementById('manual-absence-reason-select');
const errEl = document.getElementById('manual-absence-error');
if (!fio) {
if (errEl) { errEl.innerText = 'Укажите ФИО сотрудника'; errEl.classList.remove('hidden'); }
return;
}
const payload = {
absence_type: activeAbsenceType,
fio: fio,
department: deptVal,
position: posVal,
date_start: startDateVal,
date_end: endDateVal,
reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
};
try {
let res;
if (idVal) {
// Редактирование
res = await fetch(`/api/v1/manual-absences/${idVal}`, {
method: 'PUT',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ id: parseInt(idVal), date_start: startDateVal, date_end: endDateVal })
});
} else {
// Создание
res = await fetch('/api/v1/manual-absences/', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
if (res.ok) {
closeManualAbsenceModal();
if (typeof loadManualAbsencesView === 'function') {
loadManualAbsencesView(activeAbsenceType);
}
} else {
const data = await res.json();
if (errEl) { errEl.innerText = data.detail || 'Ошибка сохранения'; errEl.classList.remove('hidden'); }
}
} catch (e) {
if (errEl) { errEl.innerText = 'Сетевая ошибка при сохранении'; errEl.classList.remove('hidden'); }
}
}
// Универсальный автокомплит сотрудников
let searchTimeout = null;
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
const box = document.getElementById(suggestionsBoxId);
if (!inputEl || !box) return;
inputEl.addEventListener('input', function() {
const val = this.value.trim();
clearTimeout(searchTimeout);
if (val.length < 2) {
box.classList.add('hidden');
box.innerHTML = '';
return;
}
searchTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
if (!res.ok) return;
const items = await res.json();
if (items.length === 0) {
box.classList.add('hidden');
return;
}
box.innerHTML = items.map(it => `
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
onmousedown="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}', '${inputEl.id}', '${suggestionsBoxId}')">
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
</div>
`).join('');
box.classList.remove('hidden');
} catch (e) {
console.error(e);
}
}, 200);
});
document.addEventListener('click', (e) => {
if (!inputEl.contains(e.target) && !box.contains(e.target)) {
box.classList.add('hidden');
}
});
}
function selectStaffSuggestion(fio, dept, pos, targetInputId, boxId) {
const targetFioEl = document.getElementById(targetInputId);
if (targetFioEl) targetFioEl.value = fio;
if (targetInputId === 'rw-fio') {
const rwDept = document.getElementById('rw-dept');
if (rwDept && dept && dept !== '—') rwDept.value = dept;
} else if (targetInputId === 'manual-absence-fio-input') {
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
if (deptInput) deptInput.value = dept;
if (posInput) posInput.value = pos;
}
const box = document.getElementById(boxId);
if (box) box.classList.add('hidden');
}
document.addEventListener('DOMContentLoaded', () => {
loadAbsenceReasons();
});
+176
View File
@@ -0,0 +1,176 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/presence.js
* ROLE: Контроллер экрана оперативного мониторинга («Кто в здании прямо сейчас»).
* ===============================================================================
*/
window.PresenceManager = {
records: [],
activeTab: 'ALL',
open() {
const modal = document.getElementById('presence-modal');
if (modal) {
modal.classList.remove('hidden');
this.bindEventsOnce();
this.loadData(false);
}
},
close() {
const modal = document.getElementById('presence-modal');
if (modal) modal.classList.add('hidden');
},
bindEventsOnce() {
if (this._eventsBound) return;
this._eventsBound = true;
document.getElementById('btn-close-presence-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-presence-force-refresh')?.addEventListener('click', () => this.loadData(true));
document.querySelectorAll('.presence-tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.presence-tab-btn').forEach(b => {
b.classList.remove('bg-white', 'shadow-xs', 'text-slate-800');
b.classList.add('text-slate-600');
});
const target = e.currentTarget;
target.classList.add('bg-white', 'shadow-xs', 'text-slate-800');
target.classList.remove('text-slate-600');
this.activeTab = target.getAttribute('data-tab') || 'ALL';
this.applyFilters();
});
});
document.getElementById('presence-search-input')?.addEventListener('input', () => this.applyFilters());
document.getElementById('presence-dept-filter')?.addEventListener('change', () => this.applyFilters());
},
async loadData(forceRefresh = false) {
const subtitle = document.getElementById('presence-modal-subtitle');
const btnRefresh = document.getElementById('btn-presence-force-refresh');
if (btnRefresh && forceRefresh) {
btnRefresh.disabled = true;
btnRefresh.innerHTML = '<span>⏳</span><span>Опрос СКУД...</span>';
}
try {
const url = `/api/v1/presence/live?force_refresh=${forceRefresh}`;
const res = await fetch(url, { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка ответа сервера');
const data = await res.json();
this.render(data);
} catch (err) {
console.error('[Presence] Ошибка загрузки:', err);
if (subtitle) subtitle.textContent = 'Ошибка загрузки данных присутствия';
} finally {
if (btnRefresh) {
btnRefresh.disabled = false;
btnRefresh.innerHTML = '<span>🔄</span><span>Запросить из СКУД</span>';
}
}
},
render(data) {
this.records = data.records || [];
const m = data.metrics || {};
// Заголовок
const subtitle = document.getElementById('presence-modal-subtitle');
const sourceLabel = data.data_source === 'LIVE_MSSQL' ? '🟢 Онлайн срез Орион' : '⏱️ Локальная база СКУД';
if (subtitle) {
subtitle.textContent = `${sourceLabel} на ${data.latest_event_time || data.timestamp} | Всего в штате 1С: ${m.total_staff || 0} чел.`;
}
// Плашки метрик (строго сбалансированы)
document.getElementById('metric-total').textContent = m.total_staff || 0;
document.getElementById('metric-inside').textContent = m.inside || 0;
document.getElementById('metric-outside').textContent = m.outside || 0;
document.getElementById('metric-remote').textContent = m.remote || 0;
document.getElementById('metric-absence').textContent = m.official_absence || 0;
document.getElementById('metric-not-entered').textContent = m.not_entered || 0;
document.getElementById('metric-excluded').textContent = m.excluded || 0;
// Счетчики на табах
document.getElementById('tab-cnt-all').textContent = m.total_staff || 0;
document.getElementById('tab-cnt-inside').textContent = m.inside || 0;
document.getElementById('tab-cnt-outside').textContent = m.outside || 0;
document.getElementById('tab-cnt-remote').textContent = m.remote || 0;
document.getElementById('tab-cnt-absence').textContent = m.official_absence || 0;
document.getElementById('tab-cnt-not-entered').textContent = m.not_entered || 0;
document.getElementById('tab-cnt-excluded').textContent = m.excluded || 0;
// Выпадающий список подразделений
const deptSelect = document.getElementById('presence-dept-filter');
if (deptSelect) {
const depts = Array.from(new Set(this.records.map(r => r.department).filter(Boolean))).sort();
deptSelect.innerHTML = '<option value="ALL">Все подразделения</option>' +
depts.map(d => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join('');
}
this.applyFilters();
},
applyFilters() {
const search = (document.getElementById('presence-search-input')?.value || '').toLowerCase().trim();
const dept = document.getElementById('presence-dept-filter')?.value || 'ALL';
const filtered = this.records.filter(r => {
if (this.activeTab === 'INSIDE' && r.status !== 'INSIDE') return false;
if (this.activeTab === 'OUTSIDE' && r.status !== 'OUTSIDE') return false;
if (this.activeTab === 'REMOTE' && r.status !== 'REMOTE') return false;
if (this.activeTab === 'ABSENCE' && r.status !== 'OFFICIAL_ABSENCE') return false;
if (this.activeTab === 'NOT_ENTERED' && r.status !== 'NOT_ENTERED') return false;
if (this.activeTab === 'EXCLUDED' && r.status !== 'EXCLUDED') return false;
if (dept !== 'ALL' && r.department !== dept) return false;
if (search && !r.fio.toLowerCase().includes(search) && !r.position.toLowerCase().includes(search)) return false;
return true;
});
const tbody = document.getElementById('presence-table-body');
if (!tbody) return;
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="5" class="py-10 text-center text-slate-400">Сотрудники не найдены</td></tr>';
return;
}
tbody.innerHTML = filtered.map(r => {
let badgeClass = 'bg-slate-100 text-slate-700';
if (r.status === 'INSIDE') {
badgeClass = r.is_fligel
? 'bg-emerald-100 text-emerald-900 border border-emerald-300 font-bold'
: 'bg-emerald-100 text-emerald-800 font-bold';
} else if (r.status === 'OUTSIDE') {
badgeClass = 'bg-amber-100 text-amber-800 font-bold';
} else if (r.status === 'REMOTE') {
badgeClass = 'bg-blue-100 text-blue-800 font-semibold';
} else if (r.status === 'OFFICIAL_ABSENCE') {
badgeClass = 'bg-purple-100 text-purple-800 font-medium';
} else if (r.status === 'NOT_ENTERED') {
badgeClass = 'bg-rose-100 text-rose-800 font-semibold';
} else if (r.status === 'EXCLUDED') {
badgeClass = 'bg-slate-200 text-slate-600 font-mono text-[10px]';
}
return `
<tr class="hover:bg-slate-50 transition-colors">
<td class="py-2.5 px-3 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
<td class="py-2.5 px-3 text-slate-600 font-mono text-[11px]">${escapeHtml(r.department)}</td>
<td class="py-2.5 px-3 text-slate-500">${escapeHtml(r.position)}</td>
<td class="py-2.5 px-3 text-center">
<span class="px-2 py-0.5 rounded-md text-[11px] ${badgeClass}">${escapeHtml(r.status_label)}</span>
</td>
<td class="py-2.5 px-3 text-center text-slate-600 font-mono font-semibold">${escapeHtml(r.last_time || '—')}</td>
</tr>
`;
}).join('');
}
};
window.openPresenceModal = () => window.PresenceManager.open();
+70
View File
@@ -0,0 +1,70 @@
/**
* Роутер табов сайдбара и общие утилиты.
*/
window.escapeHtml = window.escapeHtml || function (str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const SidebarManager = {
currentTab: 'tasks',
currentRegistrySubTab: 'exceptions',
init() {
this.bindEvents();
this.switchTab('tasks');
},
bindEvents() {
document.querySelectorAll('.sidebar-tab-btn, [data-tab]').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.currentTarget.dataset.tab;
if (tab) this.switchTab(tab);
});
});
},
switchTab(tabName) {
this.currentTab = tabName;
// Переключение стилей кнопок табов
document.querySelectorAll('.sidebar-tab-btn, [data-tab]').forEach(btn => {
const isActive = btn.dataset.tab === tabName;
btn.classList.toggle('text-indigo-600', isActive);
btn.classList.toggle('border-indigo-600', isActive);
btn.classList.toggle('font-bold', isActive);
btn.classList.toggle('text-slate-500', !isActive);
btn.classList.toggle('border-transparent', !isActive);
});
// Переключение видимости вьюх
document.querySelectorAll('.sidebar-view').forEach(view => {
view.classList.add('hidden');
});
const targetView = document.getElementById(`sidebar-view-${tabName}`);
if (targetView) targetView.classList.remove('hidden');
// Вызов профильного загрузчика
if (tabName === 'tasks' && typeof loadTasks === 'function') {
loadTasks();
} else if (tabName === 'snapshots' && typeof loadSnapshotsView === 'function') {
loadSnapshotsView();
} else if (tabName === 'registries' && typeof switchRegistrySubTab === 'function') {
switchRegistrySubTab(this.currentRegistrySubTab);
} else if (tabName === 'prompts' && typeof loadPromptsView === 'function') {
loadPromptsView();
} else if (tabName === 'context' && typeof loadContextView === 'function') {
loadContextView();
}
}
};
document.addEventListener('DOMContentLoaded', () => {
SidebarManager.init();
});
@@ -0,0 +1,100 @@
/**
* Модуль вкладок "Промпт" и "Контекст".
*/
async function loadPromptsView() {
const container = document.getElementById('prompts-content-container');
if (!container) return;
container.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка промпта...</div>`;
try {
const res = await fetch('/api/v1/chat', {
method: 'POST',
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ message: "покажи системный промпт" })
});
const data = await res.json();
const text = data.response || "Промпт не получен";
container.innerHTML = `
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs space-y-2">
<div class="text-[11px] font-bold text-slate-600 flex items-center justify-between">
<span>Текущий системный промпт</span>
<button onclick="window.sendChatAction && window.sendChatAction('action:open_editor')" class="text-indigo-600 hover:text-indigo-800 text-[10px]">
<i class="fa-solid fa-pen-to-square"></i> Редактор
</button>
</div>
<pre class="text-[11px] font-mono text-slate-700 bg-slate-50 p-2.5 rounded-lg overflow-x-auto whitespace-pre-wrap leading-relaxed max-h-[65vh] border border-slate-100">${escapeHtml(text)}</pre>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить системный промпт</div>`;
}
}
async function loadContextView() {
const container = document.getElementById('context-content-container');
if (!container) return;
container.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка сессии...</div>`;
try {
const res = await fetch('/api/v1/context/state?session_id=web_session_main', { headers: AuthManager.getAuthHeaders() });
const data = await res.json();
container.innerHTML = `
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-800">Сессия: web_session_main</span>
<span class="px-2 py-0.5 text-[10px] font-bold rounded-full ${data.active_state !== 'IDLE' ? 'bg-amber-100 text-amber-800' : 'bg-slate-100 text-slate-600'}">
${escapeHtml(data.active_state)}
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-center">
<div class="p-2 bg-slate-50 rounded-lg border border-slate-100">
<div class="text-xs font-bold text-slate-700">${data.total_messages || 0}</div>
<div class="text-[10px] text-slate-400">Всего сообщений</div>
</div>
<div class="p-2 bg-slate-50 rounded-lg border border-slate-100">
<div class="text-xs font-bold text-indigo-600">${data.ephemeral_messages || 0}</div>
<div class="text-[10px] text-slate-400">Служебных (UI)</div>
</div>
</div>
<div class="space-y-1.5 pt-2 border-t border-slate-100">
<button onclick="purgeEphemeralMessages()" class="w-full py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold transition flex items-center justify-center gap-1.5">
<i class="fa-solid fa-broom text-[11px]"></i> Очистить служебные карточки
</button>
<button onclick="clearAllChatContext()" class="w-full py-2 bg-rose-50 hover:bg-rose-100 text-rose-700 rounded-lg text-xs font-semibold transition flex items-center justify-center gap-1.5">
<i class="fa-solid fa-trash-can text-[11px]"></i> Полный сброс контекста
</button>
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить состояние сессии</div>`;
}
}
async function purgeEphemeralMessages() {
try {
const res = await fetch('/api/v1/context/purge-ephemeral', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: 'web_session_main' })
});
if (res.ok) loadContextView();
} catch (e) { alert('Ошибка сети'); }
}
async function clearAllChatContext() {
if (!confirm('Полностью очистить историю сообщений диалога?')) return;
try {
const res = await fetch('/api/v1/context/clear-all', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: 'web_session_main' })
});
if (res.ok) location.reload();
} catch (e) { alert('Ошибка сети'); }
}
@@ -0,0 +1,420 @@
/**
* Модуль вкладок "Реестры": исключения, удаленщики, командировки, флигель.
*/
window.openAddExceptionModal = function(category) {
const modal = document.getElementById("exception-modal");
const catInput = document.getElementById("exception-category-input");
const valInput = document.getElementById("exception-value-input");
const commInput = document.getElementById("exception-comment-input");
const headerEl = document.getElementById("exception-modal-header-text");
const labelEl = document.getElementById("exception-value-label");
const errEl = document.getElementById("exception-error-msg");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (catInput) catInput.value = category;
if (valInput) { valInput.value = ""; valInput.focus(); }
if (commInput) commInput.value = "";
const titles = {
'include_fio': 'Белый список (ФИО)',
'fio': 'Исключенный сотрудник (ФИО)',
'departments': 'Исключенный отдел',
'positions': 'Исключенная должность',
'turnstile_fio': 'Правый турникет (ФИО)',
'turnstile_departments': 'Правый турникет (Отделы)',
'fligel_fio': 'Флигель (ФИО)',
'fligel_departments': 'Флигель (Отделы)'
};
const isDept = category.includes('department');
const isPos = category.includes('position');
if (headerEl) headerEl.innerText = "Добавить в " + (titles[category] || "реестр");
if (labelEl) {
labelEl.innerText = isDept ? 'Название подразделения:' : (isPos ? 'Название должности:' : 'ФИО сотрудника:');
}
modal.classList.remove("hidden");
};
window.closeExceptionModal = function() {
const modal = document.getElementById("exception-modal");
if (modal) modal.classList.add("hidden");
};
window.submitExceptionModalForm = async function(e) {
e.preventDefault();
const cat = document.getElementById("exception-category-input")?.value;
const val = document.getElementById("exception-value-input")?.value.trim();
const comm = document.getElementById("exception-comment-input")?.value.trim() || "";
const errEl = document.getElementById("exception-error-msg");
if (!cat || !val) {
if (errEl) { errEl.innerText = "Заполните поле"; errEl.classList.remove("hidden"); }
return;
}
try {
const res = await fetch("/api/v1/exceptions", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ category: cat, value: val, comment: comm })
});
if (res.ok) {
closeExceptionModal();
loadExceptionsView();
} else {
const data = await res.json();
if (errEl) { errEl.innerText = data.detail || "Ошибка сохранения"; errEl.classList.remove("hidden"); }
}
} catch (err) {
if (errEl) { errEl.innerText = "Ошибка соединения"; errEl.classList.remove("hidden"); }
}
};
function switchRegistrySubTab(subTab) {
if (window.SidebarManager) {
SidebarManager.currentRegistrySubTab = subTab;
}
document.querySelectorAll('.registry-subtab-btn').forEach(btn => {
const isActive = btn.dataset.subtab === subTab;
btn.classList.toggle('text-indigo-600', isActive);
btn.classList.toggle('bg-white', isActive);
btn.classList.toggle('shadow-xs', isActive);
btn.classList.toggle('font-bold', isActive);
btn.classList.toggle('text-slate-600', !isActive);
});
if (subTab === 'exceptions') loadExceptionsView();
else if (subTab === 'remote') loadRemoteWorkersView();
else if (subTab === 'local_trip') loadManualAbsencesView('LOCAL_TRIP');
else if (subTab === 'other') loadManualAbsencesView('OTHER');
}
async function loadExceptionsView() {
const container = document.getElementById('registry-content-container');
if (!container) return;
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка правил...</div>';
try {
const res = await fetch('/api/v1/exceptions', { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка сети');
const data = await res.json();
renderExceptionsView(data || {});
} catch (e) {
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить реестры исключений</div>';
}
}
function renderExceptionsView(exceptions) {
const container = document.getElementById('registry-content-container');
if (!container) return;
const renderCard = (title, list, category, badgeClass, subBadge) => {
badgeClass = badgeClass || 'bg-slate-100 text-slate-700';
subBadge = subBadge || '';
let tags = '<span class="text-[11px] text-slate-400 italic">Список пуст</span>';
if (list && list.length > 0) {
tags = list.map(item => {
const val = escapeHtml(item);
return '<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-xs font-medium ' + badgeClass + '">' +
'<span>' + val + '</span>' +
'<button data-cat="' + category + '" data-val="' + val + '" class="btn-remove-exc text-slate-400 hover:text-rose-500 transition">' +
'<i class="fa-solid fa-xmark text-[10px]"></i>' +
'</button>' +
'</span>';
}).join('');
}
const count = list ? list.length : 0;
return '<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs mb-3">' +
'<div class="flex items-center justify-between mb-2">' +
'<div class="flex items-center gap-1.5">' +
'<span class="text-xs font-bold text-slate-800">' + title + ' (' + count + ')</span>' +
subBadge +
'</div>' +
'<button data-add-cat="' + category + '" class="btn-add-exc text-xs font-bold text-indigo-600 hover:text-indigo-800">+ Добавить</button>' +
'</div>' +
'<div class="flex flex-wrap gap-1.5">' + tags + '</div>' +
'</div>';
};
container.innerHTML =
renderCard('Белый список (ФИО)', exceptions.include_fio, 'include_fio', 'bg-indigo-50 text-indigo-700') +
renderCard('Исключенные сотрудники (ФИО)', exceptions.fio, 'fio') +
renderCard('Исключенные отделы', exceptions.departments, 'departments') +
renderCard('Исключенные должности', exceptions.positions, 'positions') +
renderCard('Пр. турникет (ФИО)', exceptions.turnstile_fio, 'turnstile_fio', 'bg-emerald-50 text-emerald-700 border border-emerald-200', '<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[9px] font-bold border border-emerald-200">Оба турникета</span>') +
renderCard('Пр. турникет (Отделы)', exceptions.turnstile_departments, 'turnstile_departments', 'bg-emerald-50 text-emerald-700 border border-emerald-200', '<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[9px] font-bold border border-emerald-200">Оба турникета</span>') +
renderCard('Флигель (ФИО)', exceptions.fligel_fio, 'fligel_fio', 'bg-indigo-50 text-indigo-700 border border-indigo-200', '<span class="px-1.5 py-0.5 bg-indigo-50 text-indigo-700 rounded text-[9px] font-bold border border-indigo-200">Дверь 23</span>') +
renderCard('Флигель (Отделы)', exceptions.fligel_departments, 'fligel_departments', 'bg-indigo-50 text-indigo-700 border border-indigo-200', '<span class="px-1.5 py-0.5 bg-indigo-50 text-indigo-700 rounded text-[9px] font-bold border border-indigo-200">Дверь 23</span>');
container.querySelectorAll('.btn-add-exc').forEach(btn => {
btn.addEventListener('click', e => {
openAddExceptionModal(e.currentTarget.getAttribute('data-add-cat'));
});
});
container.querySelectorAll('.btn-remove-exc').forEach(btn => {
btn.addEventListener('click', e => {
const target = e.currentTarget;
removeExceptionItem(target.getAttribute('data-cat'), target.getAttribute('data-val'));
});
});
}
async function removeExceptionItem(category, value) {
if (!confirm('Удалить "' + value + '" из категории "' + category + '"?')) return;
try {
const res = await fetch('/api/v1/exceptions?category=' + encodeURIComponent(category) + '&value=' + encodeURIComponent(value), {
method: 'DELETE',
headers: AuthManager.getAuthHeaders()
});
if (res.ok) loadExceptionsView();
else alert('Ошибка при удалении');
} catch (e) {
alert('Ошибка сети');
}
}
async function loadRemoteWorkersView() {
const container = document.getElementById('registry-content-container');
if (!container) return;
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>';
try {
const res = await fetch('/api/v1/remote-workers', { headers: AuthManager.getAuthHeaders() });
const data = await res.json();
const workers = data.workers || [];
let listHtml = '';
if (workers.length === 0) {
listHtml = '<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">Список удаленщиков пуст</div>';
} else {
listHtml = workers.map(w => {
const fio = escapeHtml(w.fio);
const dept = escapeHtml(w.department || 'Все');
const dFrom = escapeHtml(w.date_from || '—');
const dTo = escapeHtml(w.date_to || 'бессрочно');
return '<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs text-xs flex flex-col gap-1.5">' +
'<div class="flex items-center justify-between">' +
'<span class="font-bold text-slate-800">' + fio + '</span>' +
'<div class="flex items-center gap-1.5">' +
'<button data-edit-fio="' + fio + '" data-edit-dept="' + (w.department || '') + '" data-edit-from="' + (w.date_from || '') + '" data-edit-to="' + (w.date_to || '') + '" class="btn-edit-remote text-slate-400 hover:text-emerald-600 transition" title="Редактировать"><i class="fa-solid fa-pen-to-square"></i></button>' +
'<button data-del-fio="' + fio + '" class="btn-del-remote text-slate-400 hover:text-rose-500 transition" title="Удалить"><i class="fa-solid fa-trash-can"></i></button>' +
'</div>' +
'</div>' +
'<div class="flex items-center justify-between text-[11px] text-slate-500">' +
'<span>' + dept + '</span>' +
'<span class="font-mono text-[10px] bg-slate-100 px-1.5 py-0.5 rounded">' + dFrom + ' по ' + dTo + '</span>' +
'</div>' +
'</div>';
}).join('');
}
container.innerHTML =
'<div class="flex items-center justify-between px-1 mb-2">' +
'<span class="text-xs font-bold text-slate-700">Удаленные сотрудники: ' + workers.length + '</span>' +
'<button id="btn-add-remote" class="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-xs transition">+ Добавить</button>' +
'</div>' +
'<div class="space-y-2">' + listHtml + '</div>';
const addBtn = document.getElementById('btn-add-remote');
if (addBtn) {
addBtn.addEventListener('click', () => openRemoteWorkerModal('ADD'));
}
container.querySelectorAll('.btn-edit-remote').forEach(btn => {
btn.addEventListener('click', e => {
const t = e.currentTarget;
openRemoteWorkerModal('EDIT', t.getAttribute('data-edit-fio'), t.getAttribute('data-edit-dept'), t.getAttribute('data-edit-from'), t.getAttribute('data-edit-to'));
});
});
container.querySelectorAll('.btn-del-remote').forEach(btn => {
btn.addEventListener('click', e => {
deleteRemoteWorkerItem(e.currentTarget.getAttribute('data-del-fio'));
});
});
} catch (e) {
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить удаленщиков</div>';
}
}
async function deleteRemoteWorkerItem(fio) {
if (!confirm('Удалить сотрудника "' + fio + '"?')) return;
try {
const res = await fetch('/api/v1/remote-workers?fio=' + encodeURIComponent(fio), {
method: 'DELETE',
headers: AuthManager.getAuthHeaders()
});
if (res.ok) loadRemoteWorkersView();
} catch (e) {
alert('Ошибка сети');
}
}
async function loadManualAbsencesView(type) {
const container = document.getElementById('registry-content-container');
if (!container) return;
const isTrip = (type === 'LOCAL_TRIP');
const titleText = isTrip ? 'Местные командировки' : 'Иные причины';
const btnColor = isTrip ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-purple-600 hover:bg-purple-700';
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>';
try {
const res = await fetch('/api/v1/manual-absences/?type=' + encodeURIComponent(type), {
headers: AuthManager.getAuthHeaders()
});
const data = await res.json();
const items = data.items || [];
let listHtml = '';
if (items.length === 0) {
listHtml = '<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">Нет записей</div>';
} else {
listHtml = items.map(it => {
const fio = escapeHtml(it.fio);
const reason = escapeHtml(it.reason || '');
const dept = escapeHtml(it.department || '—');
const dStart = it.date_start ? it.date_start.replace(/-/g, '.') : '';
const dEnd = it.date_end ? it.date_end.replace(/-/g, '.') : '';
// Форматирование срока
let dateBadge = '';
if (dStart && dEnd && dStart === dEnd) {
dateBadge = dStart;
} else if (dStart && dEnd) {
dateBadge = `${dStart} — ${dEnd}`;
} else if (dEnd) {
dateBadge = `по ${dEnd}`;
} else if (dStart) {
dateBadge = `с ${dStart}`;
} else {
dateBadge = 'бессрочно';
}
const badgeBg = isTrip
? 'bg-indigo-50 text-indigo-700 border-indigo-200'
: 'bg-purple-50 text-purple-700 border-purple-200';
return `
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs text-xs flex flex-col gap-1.5 hover:border-slate-300 transition">
<!-- 1-я строка: ФИО и кнопки управления -->
<div class="flex items-center justify-between gap-2">
<span class="font-bold text-slate-800 truncate" title="${fio}">${fio}</span>
<div class="flex items-center gap-1 shrink-0">
<button data-edit-id="${it.id}" data-edit-fio="${fio}" data-edit-start="${it.date_start || ''}" data-edit-end="${it.date_end || ''}"
class="btn-edit-abs text-slate-400 hover:text-indigo-600 p-1 transition" title="Редактировать сроки">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button data-del-id="${it.id}"
class="btn-del-abs text-slate-400 hover:text-rose-500 p-1 transition" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</div>
${!isTrip && reason ? `<div class="text-[11px] text-slate-600 font-medium">${reason}</div>` : ''}
<!-- 2-я строка: Отдел слева и Четкий срок отсутствия справа -->
<div class="flex items-center justify-between text-[11px] pt-1 border-t border-slate-100">
<span class="text-slate-400 truncate max-w-[200px]" title="${dept}">${dept}</span>
<span class="font-mono text-[10px] px-2 py-0.5 rounded-md border font-semibold shrink-0 ${badgeBg}">
<i class="fa-regular fa-calendar-days mr-1 text-[9px]"></i>${dateBadge}
</span>
</div>
</div>
`;
}).join('');
}
container.innerHTML = `
<div class="flex items-center justify-between px-1 mb-2">
<span class="text-xs font-bold text-slate-700">${titleText}: ${items.length}</span>
<button id="btn-add-absence" class="px-2.5 py-1 ${btnColor} text-white rounded-lg text-xs font-bold shadow-xs transition">+ Добавить</button>
</div>
<div class="space-y-2">${listHtml}</div>
`;
const addBtn = document.getElementById('btn-add-absence');
if (addBtn) {
addBtn.addEventListener('click', () => openManualAbsenceModal(type));
}
// Слушатели кнопок редактирования
container.querySelectorAll('.btn-edit-abs').forEach(btn => {
btn.addEventListener('click', e => {
const t = e.currentTarget;
const id = t.getAttribute('data-edit-id');
const fio = t.getAttribute('data-edit-fio');
const start = t.getAttribute('data-edit-start');
const end = t.getAttribute('data-edit-end');
openManualAbsenceModal(type, {
id: id,
fio: fio,
date_start: start,
date_end: end
});
});
});
// Слушатели кнопок удаления
container.querySelectorAll('.btn-del-abs').forEach(btn => {
btn.addEventListener('click', e => {
deleteManualAbsenceRecord(e.currentTarget.getAttribute('data-del-id'), type);
});
});
} catch (e) {
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Ошибка загрузки</div>';
}
}
// Быстрое модальное окно / диалог изменения дат
async function openEditAbsenceDatesModal(id, fio, dateStart, dateEnd, type) {
const newEnd = prompt(`Укажите новую дату окончания для сотрудника:\n${fio}\n(формат ГГГГ-ММ-ДД):`, dateEnd || '');
if (newEnd === null) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, {
method: 'PUT',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
id: parseInt(id),
date_start: dateStart || null,
date_end: newEnd.trim() || null
})
});
if (res.ok) {
loadManualAbsencesView(type);
} else {
alert('Не удалось обновить дату');
}
} catch (err) {
alert('Ошибка сети при обновлении');
}
}
async function deleteManualAbsenceRecord(id, type) {
if (!confirm('Удалить эту запись?')) return;
try {
const res = await fetch('/api/v1/manual-absences/' + encodeURIComponent(id), {
method: 'DELETE',
headers: AuthManager.getAuthHeaders()
});
if (res.ok) loadManualAbsencesView(type);
} catch (e) {
alert('Ошибка сети');
}
}
@@ -0,0 +1,308 @@
/**
* Модуль вкладки "Срезы": список снапшотов по диапазону дат, создание, инспекция и генерация отчетов.
*/
// 1. Вспомогательные функции форматирования дат
function formatDateDDMMYYYY(d) {
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
function formatDateToISO(d) {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function isoToBackendFormat(isoStr) {
if (!isoStr) return '';
if (isoStr.includes('.')) return isoStr;
const [y, m, d] = isoStr.split('-');
return `${d}.${m}.${y}`;
}
// 2. Автозаполнение полей создания среза текущими датой и временем
function initManualSnapshotInputs() {
const now = new Date();
const dateInput = document.getElementById('manual-snapshot-date');
const timeInput = document.getElementById('manual-snapshot-time');
if (dateInput) {
dateInput.value = formatDateDDMMYYYY(now);
}
if (timeInput) {
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
timeInput.value = `${hh}:${mm}`;
}
}
// 3. Быстрые пресеты периода
window.setSnapshotDatePreset = function(preset) {
const today = new Date();
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (!fromInput || !toInput) return;
if (preset === 'today') {
const str = formatDateToISO(today);
fromInput.value = str;
toInput.value = str;
} else if (preset === 'yesterday') {
const y = new Date();
y.setDate(today.getDate() - 1);
const str = formatDateToISO(y);
fromInput.value = str;
toInput.value = str;
} else if (preset === 'days3') {
const start = new Date();
start.setDate(today.getDate() - 2);
fromInput.value = formatDateToISO(start);
toInput.value = formatDateToISO(today);
} else if (preset === 'days7') {
const start = new Date();
start.setDate(today.getDate() - 6);
fromInput.value = formatDateToISO(start);
toInput.value = formatDateToISO(today);
}
loadSnapshotsView();
};
// 4. Загрузка списка срезов с группировкой и сортировкой
async function loadSnapshotsView() {
// Гарантированно заполняем поля даты и времени создания среза
initManualSnapshotInputs();
const listContainer = document.getElementById('snapshots-list');
const countBadge = document.getElementById('snapshots-count-badge');
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (!listContainer) return;
const todayIso = formatDateToISO(new Date());
if (fromInput && !fromInput.value) fromInput.value = todayIso;
if (toInput && !toInput.value) toInput.value = todayIso;
const dateFrom = fromInput ? isoToBackendFormat(fromInput.value) : isoToBackendFormat(todayIso);
const dateTo = toInput ? isoToBackendFormat(toInput.value) : dateFrom;
listContainer.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
try {
const url = `/api/v1/snapshots?date_from=${encodeURIComponent(dateFrom)}&date_to=${encodeURIComponent(dateTo)}`;
const res = await fetch(url, { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка сети');
const data = await res.json();
const snapshots = data.snapshots || [];
if (countBadge) countBadge.innerText = `Срезы в базе: ${snapshots.length}`;
if (snapshots.length === 0) {
listContainer.innerHTML = `
<div class="text-center py-8 text-slate-400 text-xs space-y-2">
<p>За выбранный период срезы не найдены</p>
<button onclick="setSnapshotDatePreset('yesterday')" class="px-2.5 py-1 text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition font-medium shadow-2xs">
Показать за вчера
</button>
</div>
`;
return;
}
// Вспомогательный ключ для сортировки от новых к старым
function getSortKey(s) {
const sid = (s.snapshot_id || s.id || '').toUpperCase();
const digits = sid.replace(/\D/g, '');
if (sid.includes('FINAL') && digits.length >= 8) {
return `${digits.substring(0, 8)}_235959`;
}
return digits.padEnd(14, '0');
}
snapshots.sort((a, b) => getSortKey(b).localeCompare(getSortKey(a)));
// Группировка по дням
const groups = {};
snapshots.forEach(s => {
const sid = s.snapshot_id || s.id || '';
let groupDate = s.date || '';
if (!groupDate) {
const digits = sid.replace(/\D/g, '');
if (digits.length >= 8) {
const y = digits.substring(0, 4);
const m = digits.substring(4, 6);
const d = digits.substring(6, 8);
groupDate = `${d}.${m}.${y}`;
} else {
groupDate = 'Другие срезы';
}
}
if (!groups[groupDate]) groups[groupDate] = [];
groups[groupDate].push(s);
});
// Сортировка дат по убыванию
const sortedDates = Object.keys(groups).sort((d1, d2) => {
const parseDate = (str) => {
const p = str.split('.');
return p.length === 3 ? new Date(p[2], p[1] - 1, p[0]).getTime() : 0;
};
return parseDate(d2) - parseDate(d1);
});
let html = '';
for (const dateLabel of sortedDates) {
const items = groups[dateLabel];
html += `
<div class="pt-2 pb-1 flex items-center gap-2">
<span class="text-[11px] font-bold text-slate-600 uppercase tracking-wider flex items-center gap-1.5">
<i class="fa-regular fa-calendar-days text-indigo-500 text-xs"></i> ${escapeHtml(dateLabel)}
</span>
<div class="h-px bg-slate-200 flex-1"></div>
<span class="text-[10px] text-slate-400 font-semibold">${items.length} срез.</span>
</div>
`;
html += items.map(s => {
const sid = s.snapshot_id || s.id;
const isFinal = Boolean(s.is_final) || sid.toUpperCase().includes('FINAL');
const badgeFinal = isFinal ? `<span class="ml-1.5 px-1.5 py-0.5 bg-amber-100 text-amber-800 rounded text-[9px] font-bold">Финал Y</span>` : '';
const cnt = s.record_count ?? s.count ?? s.records_count ?? 0;
const timeStr = s.snapshot_time || s.time || '—';
return `
<div class="p-3 bg-white rounded-xl border border-slate-200 hover:border-indigo-200 transition shadow-xs flex items-center justify-between group mb-2">
<div>
<div class="flex items-center">
<span class="text-xs font-bold font-mono text-slate-800">${escapeHtml(sid)}</span>
${badgeFinal}
</div>
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(timeStr)} · ${cnt} зап.</div>
</div>
<div class="flex items-center gap-1.5">
<button onclick="openSnapshotInspector('${escapeHtml(sid)}')" class="px-2.5 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition">Инспекция</button>
${!isFinal ? `
<button onclick="deleteSnapshotItem('${escapeHtml(sid)}')" class="w-6 h-6 flex items-center justify-center text-slate-300 hover:text-rose-500 rounded transition opacity-0 group-hover:opacity-100" title="Удалить срез"><i class="fa-regular fa-trash-can text-[11px]"></i></button>
` : ''}
</div>
</div>
`;
}).join('');
}
listContainer.innerHTML = html;
} catch (e) {
listContainer.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить срезы</div>`;
}
}
// 5. Ручное создание среза
async function createSnapshotManual() {
const dateVal = document.getElementById('manual-snapshot-date')?.value || '';
const timeVal = document.getElementById('manual-snapshot-time')?.value || '';
const btn = document.getElementById('btn-create-snapshot');
if (!dateVal) { alert('Укажите дату среза'); return; }
btn.disabled = true;
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-1.5"></i> Создание...`;
try {
const res = await fetch('/api/v1/snapshots/create', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ date_str: dateVal, time_str: timeVal })
});
if (res.ok) {
loadSnapshotsView();
} else {
const data = await res.json();
alert(`Ошибка: ${data.detail || 'Не удалось сформировать срез'}`);
}
} catch (e) {
alert('Ошибка сети');
} finally {
btn.disabled = false;
btn.innerHTML = `<i class="fa-solid fa-camera mr-1.5"></i> Сделать срез`;
}
}
// 6. Удаление среза
async function deleteSnapshotItem(snapshotId) {
if (!confirm(`Удалить срез ${snapshotId}?`)) return;
try {
const res = await fetch('/api/v1/snapshots', {
method: 'DELETE',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ snapshot_ids: [snapshotId] })
});
if (res.ok) loadSnapshotsView();
} catch (e) { alert('Ошибка сети'); }
}
// 7. Генерация отчетов On-Demand
window.generateReportDirect = async function(reportType) {
const fromInput = document.getElementById('snapshots-date-from');
const dateInput = fromInput ? isoToBackendFormat(fromInput.value) : '';
const labelMap = {
'SVODKA': 'Сводки',
'SIMPLIFIED': 'Упрощенного отчета',
'DETAILED': 'Детального отчета'
};
const targetLabel = labelMap[reportType] || 'отчета';
try {
const res = await fetch('/api/v1/reports/generate', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
report_type: reportType,
date: dateInput || null,
time: null // ⭐️ Всегда берем последний готовый срез
})
});
if (!res.ok) {
alert(`Ошибка сервера (${res.status}) при создании ${targetLabel}`);
return;
}
const data = await res.json();
const rep = data.reports?.[0];
if (rep && rep.status === 'success' && rep.download_url) {
window.open(rep.download_url, '_blank');
} else {
alert(`Ошибка: ${rep?.error || 'Не удалось сформировать файл'}`);
}
} catch (e) {
console.error('[Reports] Ошибка генерации:', e);
alert('Сетевая ошибка при запросе формирования отчета');
}
};
// 8. Инициализация при первичной загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
const todayIso = formatDateToISO(new Date());
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (fromInput && !fromInput.value) fromInput.value = todayIso;
if (toInput && !toInput.value) toInput.value = todayIso;
initManualSnapshotInputs();
});
// Экспортируем в глобальную область, чтобы core.js при переключении вкладок вызывал эту функцию
window.loadSnapshotsView = loadSnapshotsView;
window.createSnapshotManual = createSnapshotManual;
window.deleteSnapshotItem = deleteSnapshotItem;
@@ -0,0 +1,138 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/snapshot_inspector.js
* ROLE: Управление модальным окном полноразмерной инспекции срезов СКУД
* (фильтрация по ФИО, статусам, подразделениям и табличное представление).
* ===============================================================================
*/
let currentInspectorData = [];
let currentActiveSnapshotId = null;
async function openSnapshotInspector(snapshotId) {
currentActiveSnapshotId = snapshotId;
const modal = document.getElementById("snapshot-inspector-modal");
const titleEl = document.getElementById("inspector-modal-title");
const subEl = document.getElementById("inspector-modal-subtitle");
const tbody = document.getElementById("inspector-table-body");
if (!modal) return;
modal.classList.remove("hidden");
titleEl.innerText = `Инспекция среза #${snapshotId}`;
subEl.innerText = "Загрузка данных из базы...";
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-2"></i> Загрузка записей...</td></tr>`;
try {
const res = await fetch(`/api/v1/snapshots/${snapshotId}/details`, { headers: AuthManager.getAuthHeaders() });
if (res.ok) {
const data = await res.json();
currentInspectorData = data.records || [];
subEl.innerText = `Дата: ${data.date} · Всего записей: ${currentInspectorData.length}`;
populateDepartmentFilter(currentInspectorData);
renderInspectorTable(currentInspectorData);
} else {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-rose-500">Ошибка загрузки деталей среза</td></tr>`;
}
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-rose-500">Ошибка сети</td></tr>`;
}
}
function closeSnapshotInspectorModal() {
const modal = document.getElementById("snapshot-inspector-modal");
if (modal) modal.classList.add("hidden");
}
function populateDepartmentFilter(records) {
const select = document.getElementById("inspector-filter-dept");
if (!select) return;
const depts = [...new Set(records.map(r => r.department || r.Подразделение || "Без подразделения"))].sort();
select.innerHTML = `<option value="ALL">Все подразделения (${depts.length})</option>` +
depts.map(d => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join('');
}
function renderInspectorTable(records) {
const tbody = document.getElementById("inspector-table-body");
const countEl = document.getElementById("inspector-records-count");
if (!tbody) return;
if (records.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-slate-400">Нет записей, соответствующих фильтрам</td></tr>`;
if (countEl) countEl.innerText = "Показано записей: 0";
return;
}
tbody.innerHTML = records.map((r, idx) => {
const fio = r.fio || r.Сотрудник || "—";
const dept = r.department || r.Подразделение || "—";
const timeIn = r.time_in || r.Начало_дня || "Нет входа";
const firstAct = r.first_activity || r.Первая_активность || "—";
const timeOut = r.time_out || r.Конец_дня || "Нет выхода";
const duration = r.duration || r.Находился_в_здании || "00:00";
// ⭐️ Жестко определяем присутствие по факту наличия времени входа
const isPresent = timeIn && timeIn !== "Нет входа" && timeIn !== "—";
// Корректируем статус: если человек зашел, он точно присутствует
let status = r.status || r.Статус || "";
if (!status || status.includes("Отсутствовал") || status.includes("Нет событий")) {
status = isPresent ? "Присутствовал" : "Отсутствовал";
} else if (isPresent && !status.includes("Присутствовал")) {
status = "Присутствовал";
}
return `
<tr class="hover:bg-slate-50 transition">
<td class="p-3 text-slate-400 font-mono text-[11px]">${idx + 1}</td>
<td class="p-3 font-bold text-slate-800">${escapeHtml(fio)}</td>
<td class="p-3 text-slate-600">${escapeHtml(dept)}</td>
<td class="p-3 font-mono ${isPresent ? 'text-emerald-600 font-semibold' : 'text-slate-400'}">${escapeHtml(timeIn)}</td>
<td class="p-3 font-mono text-slate-500">${escapeHtml(firstAct)}</td>
<td class="p-3 font-mono text-slate-600">${escapeHtml(timeOut)}</td>
<td class="p-3 font-mono font-semibold text-slate-700">${escapeHtml(duration)}</td>
<td class="p-3">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${isPresent ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-slate-100 text-slate-500'}">
${escapeHtml(status)}
</span>
</td>
</tr>
`;
}).join('');
if (countEl) countEl.innerText = `Показано записей: ${records.length} из ${currentInspectorData.length}`;
}
function filterInspectorTable() {
const query = document.getElementById("inspector-search-input")?.value.toLowerCase() || "";
const statusFilter = document.getElementById("inspector-filter-status")?.value || "ALL";
const deptFilter = document.getElementById("inspector-filter-dept")?.value || "ALL";
const filtered = currentInspectorData.filter(r => {
const fio = (r.fio || r.Сотрудник || "").toLowerCase();
const dept = r.department || r.Подразделение || "Без подразделения";
const status = r.status || r.Статус || "";
const timeIn = r.time_in || r.Начало_дня || "Нет входа";
const isPresent = status.includes("Присутствовал") || timeIn !== "Нет входа";
const matchesQuery = fio.includes(query) || dept.toLowerCase().includes(query);
const matchesDept = deptFilter === "ALL" || dept === deptFilter;
let matchesStatus = true;
if (statusFilter === "PRESENT") matchesStatus = isPresent;
if (statusFilter === "ABSENT") matchesStatus = !isPresent;
return matchesQuery && matchesDept && matchesStatus;
});
renderInspectorTable(filtered);
}
window.downloadInspectorExport = function(format = 'xlsx') {
if (!currentActiveSnapshotId) {
alert('Срез не выбран');
return;
}
const cleanId = String(currentActiveSnapshotId).replace(/^#/, '').trim();
const url = `/api/v1/snapshots/${encodeURIComponent(cleanId)}/export?format=${format}`;
window.open(url, '_blank');
};
+171 -106
View File
@@ -1,145 +1,210 @@
let currentFilter = 'ALL';
let allTasks = [];
/**
* ===============================================================================
* FILE: modules/web_api/static/js/tasks.js
* ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
* ===============================================================================
*/
function toggleDrawer() {
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
const drawer = document.getElementById("task-drawer");
const backdrop = document.getElementById("drawer-backdrop");
if (!drawer) return;
window.escapeHtml = window.escapeHtml || function (str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const isHidden = drawer.classList.contains("translate-x-full");
if (isHidden) {
drawer.classList.remove("translate-x-full");
if (backdrop) backdrop.classList.remove("hidden");
loadTasks();
} else {
drawer.classList.add("translate-x-full");
if (backdrop) backdrop.classList.add("hidden");
}
}
let currentTasksFilter = 'ALL';
let tasksCache = [];
function setFilter(status) {
currentFilter = status;
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
const btn = document.getElementById(`filter-${f}`);
if (btn) {
btn.className = (f === status)
? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
: "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
}
});
renderTasks();
function getTasksContainer() {
return document.getElementById("tasks-list-container") ||
document.getElementById("sidebar-dynamic-content") ||
document.getElementById("tasks-list");
}
async function loadTasks() {
const badge = document.getElementById("task-count-badge");
const container = document.getElementById("tasks-container");
const container = getTasksContainer();
if (!container) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
if (isGuest || !token) {
if (badge) badge.innerText = "0";
return;
}
container.innerHTML = `
<div class="text-center py-8 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
</div>
`;
try {
const res = await fetch("/api/v1/tasks", {
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
}
headers: AuthManager.getAuthHeaders()
});
if (!res.ok) {
if (res.status === 401) {
if (typeof logout === 'function') logout();
showAuthModal();
return;
}
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
throw new Error(`Ошибка сервера (${res.status})`);
}
const data = await res.json();
// Гибкое определение структуры данных (массив или объект с ключом tasks)
if (Array.isArray(data)) {
allTasks = data;
} else if (data && Array.isArray(data.tasks)) {
allTasks = data.tasks;
} else if (data && typeof data === 'object') {
allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
} else {
allTasks = [];
}
if (badge) {
badge.innerText = allTasks.length.toString();
}
renderTasks();
tasksCache = Array.isArray(data) ? data : (data.tasks || []);
renderTasksUI();
} catch (err) {
console.error("[Tasks Error]", err);
if (badge) badge.innerText = "0";
if (container) {
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
}
console.error("[Tasks] Ошибка загрузки:", err);
container.innerHTML = `
<div class="p-4 text-xs text-rose-500 text-center flex flex-col items-center gap-2">
<i class="fa-solid fa-triangle-exclamation text-base"></i>
<span>Не удалось загрузить задачи</span>
<button onclick="loadTasks()" class="px-2.5 py-1 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded text-[11px] font-semibold transition">Повторить</button>
</div>
`;
}
}
function renderTasks() {
const container = document.getElementById("tasks-container");
function setTaskFilter(filter) {
currentTasksFilter = filter;
renderTasksUI();
}
function renderTasksUI() {
const container = getTasksContainer();
if (!container) return;
if (!Array.isArray(allTasks)) {
allTasks = [];
let filtered = tasksCache;
if (currentTasksFilter === 'IN_PROGRESS') {
filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS');
} else if (currentTasksFilter === 'BACKLOG') {
filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED');
} else if (currentTasksFilter === 'COMPLETED') {
filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE');
}
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
const filtersHtml = `
<div class="flex items-center gap-1 p-1 bg-slate-200/70 rounded-lg text-[11px] font-semibold mb-2">
<button onclick="setTaskFilter('ALL')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'ALL' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Все</button>
<button onclick="setTaskFilter('IN_PROGRESS')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'IN_PROGRESS' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">В работе</button>
<button onclick="setTaskFilter('BACKLOG')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'BACKLOG' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Планы</button>
<button onclick="setTaskFilter('COMPLETED')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'COMPLETED' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Готово</button>
</div>
`;
const addBtnHtml = `
<div class="flex items-center justify-between px-1 mb-1.5">
<span class="text-xs font-bold text-slate-700">Задачи: ${filtered.length}</span>
<button onclick="openCreateTaskModal()" class="px-2 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Новая
</button>
</div>
`;
if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
container.innerHTML = `
${filtersHtml}
${addBtnHtml}
<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">
Нет задач в выбранной категории
</div>
`;
return;
}
container.innerHTML = filtered.map(t => {
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
let cardBg = "bg-white";
if (t.status === "COMPLETED") {
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
cardBg = "bg-emerald-50/20";
} else if (t.status === "IN_PROGRESS") {
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
cardBg = "bg-amber-50/20 border-amber-200";
}
let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
let dueDateHtml = t.due_date ? `
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
<i class="fa-solid fa-clock text-amber-600"></i>
<span>Срок: ${t.due_date}</span>
</div>` : "";
const itemsHtml = filtered.map(t => {
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
const priorityColors = {
'HIGH': 'bg-rose-50 text-rose-700 border-rose-200',
'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200',
'LOW': 'bg-slate-50 text-slate-600 border-slate-200'
};
const pClass = priorityColors[t.priority] || priorityColors['MEDIUM'];
return `
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center gap-1.5">
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span>
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
<div class="flex flex-col p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-1.5 shadow-sm hover:border-indigo-300 transition">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<button onclick="toggleTaskStatus(${t.id}, '${t.status}')" class="text-slate-400 hover:text-indigo-600 transition shrink-0">
<i class="fa-${isDone ? 'solid fa-circle-check text-emerald-500' : 'regular fa-circle'} text-sm"></i>
</button>
<span class="font-bold text-slate-800 ${isDone ? 'line-through text-slate-400' : ''} truncate">${escapeHtml(t.title)}</span>
</div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status || 'BACKLOG'}</span>
<span class="px-1.5 py-0.5 rounded text-[9px] font-semibold border ${pClass} shrink-0">${t.priority || 'NORMAL'}</span>
</div>
<div class="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-100">
<span>${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')}</span>
<div class="flex items-center gap-1">
<button onclick="deleteTaskItem(${t.id})" class="text-slate-400 hover:text-rose-600 p-0.5 transition" title="Удалить">
<i class="fa-solid fa-trash-can text-[11px]"></i>
</button>
</div>
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
<i class="fa-solid fa-folder-closed text-slate-300"></i>
<span>${t.module || 'General'}</span>
</div>
${dueDateHtml}
</div>
`;
}).join("");
}).join('');
container.innerHTML = `
${filtersHtml}
${addBtnHtml}
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${itemsHtml}
</div>
`;
}
async function toggleTaskStatus(id, currentStatus) {
const newStatus = (currentStatus === 'COMPLETED' || currentStatus === 'DONE') ? 'IN_PROGRESS' : 'COMPLETED';
try {
const res = await fetch(`/api/v1/tasks/${id}`, {
method: "PATCH",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
loadTasks();
}
} catch (e) {
console.error("Ошибка смены статуса задачи:", e);
}
}
async function deleteTaskItem(id) {
if (!confirm("Удалить эту задачу?")) return;
try {
const res = await fetch(`/api/v1/tasks/${id}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) {
loadTasks();
}
} catch (e) {
alert("Ошибка сети при удалении");
}
}
async function openCreateTaskModal() {
const title = prompt("Введите описание новой задачи:");
if (!title || !title.trim()) return;
try {
const res = await fetch("/api/v1/tasks", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
title: title.trim(),
priority: "MEDIUM",
status: "IN_PROGRESS"
})
});
if (res.ok) {
loadTasks();
} else {
alert("Не удалось создать задачу");
}
} catch (e) {
alert("Ошибка сети");
}
}
window.loadTasks = loadTasks;
window.setTaskFilter = setTaskFilter;
@@ -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,59 @@
<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 id="manual-absence-modal-title-text">Добавление в реестр</span>
</h3>
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition">
<i class="fa-solid fa-xmark text-base"></i>
</button>
</div>
<form id="manual-absence-form" onsubmit="submitManualAbsence(event)" class="flex flex-col gap-3">
<input type="hidden" id="manual-absence-id" value="" />
<input type="hidden" id="manual-absence-dept" value="" />
<input type="hidden" id="manual-absence-pos" value="" />
<div class="relative">
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
<input type="text" id="manual-absence-fio-input" autocomplete="off" required placeholder="Начните вводить фамилию..."
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800 transition" />
<div id="manual-absence-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
</div>
<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 text-slate-800"></select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
<input type="date" id="manual-absence-start-date" required
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Дата выхода/отбытия</span>
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
<input type="date" id="manual-absence-end-date" required
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Включительно</span>
</div>
</div>
<div id="manual-absence-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<div class="flex items-center justify-end gap-2 mt-2 pt-3 border-t border-slate-100">
<button type="button" onclick="closeManualAbsenceModal()"
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
Отмена
</button>
<button type="submit" id="manual-absence-submit-btn"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow-xs transition">
Сохранить
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,101 @@
<div id="presence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl h-[88vh] flex flex-col overflow-hidden border border-slate-200">
<!-- Шапка -->
<div class="px-6 py-4 border-b border-slate-200 bg-slate-50/80 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 text-emerald-600 flex items-center justify-center font-bold text-xl">
🏢
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">Оперативный мониторинг присутствия</h3>
<p id="presence-modal-subtitle" class="text-xs text-slate-500">Загрузка данных...</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button id="btn-presence-force-refresh" class="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-semibold flex items-center space-x-1.5 transition-colors shadow-sm">
<span>🔄</span>
<span>Запросить из СКУД</span>
</button>
<button id="btn-close-presence-modal" class="text-slate-400 hover:text-slate-600 p-2 rounded-lg hover:bg-slate-100 transition-colors">
✕
</button>
</div>
</div>
<!-- Метрики (плашки) - 7 колонок для точного баланса штата 1С -->
<div class="grid grid-cols-2 sm:grid-cols-7 gap-2 p-4 bg-slate-100/60 border-b border-slate-200">
<div class="bg-white p-2.5 rounded-xl border border-slate-200/80 text-center">
<div class="text-[11px] text-slate-500 font-medium">Штат 1С</div>
<div id="metric-total" class="text-lg font-bold text-slate-800">—</div>
</div>
<div class="bg-emerald-50 p-2.5 rounded-xl border border-emerald-200/80 text-center">
<div class="text-[11px] text-emerald-700 font-medium">В здании</div>
<div id="metric-inside" class="text-lg font-bold text-emerald-700">—</div>
</div>
<div class="bg-amber-50 p-2.5 rounded-xl border border-amber-200/80 text-center">
<div class="text-[11px] text-amber-700 font-medium">Вышли</div>
<div id="metric-outside" class="text-lg font-bold text-amber-700">—</div>
</div>
<div class="bg-blue-50 p-2.5 rounded-xl border border-blue-200/80 text-center">
<div class="text-[11px] text-blue-700 font-medium">Удаленка</div>
<div id="metric-remote" class="text-lg font-bold text-blue-700">—</div>
</div>
<div class="bg-purple-50 p-2.5 rounded-xl border border-purple-200/80 text-center">
<div class="text-[11px] text-purple-700 font-medium">Отпуск/Ком.</div>
<div id="metric-absence" class="text-lg font-bold text-purple-700">—</div>
</div>
<div class="bg-rose-50 p-2.5 rounded-xl border border-rose-200/80 text-center">
<div class="text-[11px] text-rose-700 font-medium">Не пришли</div>
<div id="metric-not-entered" class="text-lg font-bold text-rose-700">—</div>
</div>
<div class="bg-slate-200/70 p-2.5 rounded-xl border border-slate-300 text-center">
<div class="text-[11px] text-slate-600 font-medium">Исключения</div>
<div id="metric-excluded" class="text-lg font-bold text-slate-700">—</div>
</div>
</div>
<!-- Панель управления и фильтрации -->
<div class="p-4 border-b border-slate-200 flex flex-wrap items-center justify-between gap-3 bg-white">
<!-- Табы статусов -->
<div class="flex items-center space-x-1 overflow-x-auto bg-slate-100 p-1 rounded-xl text-xs font-semibold">
<button class="presence-tab-btn px-3 py-1.5 rounded-lg bg-white shadow-xs text-slate-800" data-tab="ALL">Все (<span id="tab-cnt-all">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="INSIDE">В здании (<span id="tab-cnt-inside">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="OUTSIDE">Вышли (<span id="tab-cnt-outside">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="REMOTE">Удаленка (<span id="tab-cnt-remote">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="ABSENCE">Отсутствуют (<span id="tab-cnt-absence">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="NOT_ENTERED">Не пришли (<span id="tab-cnt-not-entered">0</span>)</button>
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="EXCLUDED">Исключения (<span id="tab-cnt-excluded">0</span>)</button>
</div>
<!-- Поиск по ФИО и фильтр отдела -->
<div class="flex items-center space-x-2 w-full sm:w-auto">
<input type="text" id="presence-search-input" placeholder="🔍 Поиск сотрудника..." class="text-xs px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg w-48 focus:outline-emerald-500 focus:bg-white">
<select id="presence-dept-filter" class="text-xs px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg max-w-[180px] focus:outline-emerald-500">
<option value="ALL">Все подразделения</option>
</select>
</div>
</div>
<!-- Таблица сотрудников -->
<div class="flex-1 overflow-y-auto p-4">
<table class="w-full text-left text-xs border-collapse">
<thead class="sticky top-0 bg-slate-50 border-b border-slate-200 text-slate-500 uppercase tracking-wider">
<tr>
<th class="py-2.5 px-3 font-semibold">Сотрудник</th>
<th class="py-2.5 px-3 font-semibold">Отдел</th>
<th class="py-2.5 px-3 font-semibold">Должность</th>
<th class="py-2.5 px-3 font-semibold text-center">Статус</th>
<th class="py-2.5 px-3 font-semibold text-center">Отметка</th>
</tr>
</thead>
<tbody id="presence-table-body" class="divide-y divide-slate-100">
<tr>
<td colspan="5" class="py-8 text-center text-slate-400">Загрузка данных...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -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,58 @@
<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 class="relative">
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
<input type="text" id="rw-fio" required autocomplete="off" placeholder="Начните вводить фамилию..."
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
<div id="rw-fio-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
</div>
<div>
<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>
@@ -0,0 +1,94 @@
<div id="snapshot-inspector-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-6xl h-[90vh] flex flex-col overflow-hidden">
<!-- ШАПКА МОДАЛКИ ИНСПЕКЦИИ СРЕЗА -->
<div class="px-6 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-indigo-600 text-white flex items-center justify-center shadow-xs">
<i class="fa-solid fa-magnifying-glass-chart text-sm"></i>
</div>
<div>
<h3 id="inspector-modal-title" class="text-sm font-bold text-slate-800">Инспекция среза</h3>
<p id="inspector-modal-subtitle" class="text-[11px] text-slate-400">Загрузка данных...</p>
</div>
</div>
<!-- КНОПКИ ЭКСПОРТА И ЗАКРЫТИЯ -->
<div class="flex items-center gap-2">
<button onclick="downloadInspectorExport('xlsx')"
class="px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 shadow-xs"
title="Скачать срез в формате Excel (.xlsx)">
<i class="fa-solid fa-file-excel text-emerald-600"></i>
<span>Excel</span>
</button>
<button onclick="downloadInspectorExport('csv')"
class="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-200 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 shadow-xs"
title="Скачать срез в CSV (для анализа нейросетями)">
<i class="fa-solid fa-file-csv text-slate-600"></i>
<span>CSV</span>
</button>
<button onclick="closeSnapshotInspectorModal()"
class="text-slate-400 hover:text-slate-600 p-2 rounded-lg hover:bg-slate-200 transition ml-1"
title="Закрыть">
<i class="fa-solid fa-xmark text-base"></i>
</button>
</div>
</div>
<!-- ПАНЕЛЬ ФИЛЬТРОВ И ПОИСКА -->
<div class="px-6 py-3 bg-white border-b border-slate-200 flex flex-wrap items-center gap-3 shrink-0">
<div class="flex-1 min-w-[240px] relative">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-2.5 text-slate-400 text-xs"></i>
<input type="text" id="inspector-search-input" oninput="filterInspectorTable()" placeholder="Поиск по ФИО или должности..."
class="w-full text-xs pl-8 pr-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
</div>
<div class="flex items-center gap-2">
<select id="inspector-filter-status" onchange="filterInspectorTable()" class="text-xs px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500">
<option value="ALL">Все статусы</option>
<option value="PRESENT">Присутствовал</option>
<option value="ABSENT">Отсутствовал</option>
<option value="ANOMALY">Аномалии</option>
</select>
<select id="inspector-filter-dept" onchange="filterInspectorTable()" class="text-xs px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500">
<option value="ALL">Все подразделения</option>
</select>
</div>
</div>
<!-- ТАБЛИЦА ДАННЫХ (СКРОЛЛИРУЕМАЯ ОБЛАСТЬ НА ВСЮ ВЫСОТУ) -->
<div class="flex-1 overflow-y-auto p-6 bg-slate-50">
<div class="bg-white border border-slate-200 rounded-xl shadow-xs overflow-hidden">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-100 border-b border-slate-200 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
<th class="p-3">#</th>
<th class="p-3">Сотрудник</th>
<th class="p-3">Подразделение</th>
<th class="p-3">Вход</th>
<th class="p-3">Первая акт.</th>
<th class="p-3">Выход</th>
<th class="p-3">В здании</th>
<th class="p-3">Статус</th>
</tr>
</thead>
<tbody id="inspector-table-body" class="divide-y divide-slate-100 text-xs text-slate-700">
<tr><td colspan="8" class="text-center py-8 text-slate-400">Выберите срез для инспекции</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ПОДВАЛ МОДАЛКИ -->
<div class="px-6 py-3 bg-white border-t border-slate-200 flex items-center justify-between shrink-0">
<span id="inspector-records-count" class="text-xs font-semibold text-slate-500">Записей не найдено</span>
<button onclick="closeSnapshotInspectorModal()" class="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-xs font-bold transition">
Закрыть
</button>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -e
cd /home/apushkov/projects/scud_ai
mkdir -p /home/apushkov/projects/scud_ai/logs
echo "==================================================" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log
echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log
echo "==================================================" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log
/home/apushkov/projects/scud_ai/venv/bin/python /home/apushkov/projects/scud_ai/main_etl.py >> /home/apushkov/projects/scud_ai/logs/cron_etl.log 2>&1
echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log
echo "" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -e
cd /home/apushkov/projects/scud_ai
mkdir -p /home/apushkov/projects/scud_ai/logs
echo "[CRON HOURLY START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_hourly.log
/home/apushkov/projects/scud_ai/venv/bin/python /home/apushkov/projects/scud_ai/services/scud_export.py >> /home/apushkov/projects/scud_ai/logs/cron_hourly.log 2>&1
echo "[CRON HOURLY FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_hourly.log
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -e
cd /home/apushkov/projects/scud_ai
mkdir -p /home/apushkov/projects/scud_ai/logs
echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_reports.log
/home/apushkov/projects/scud_ai/venv/bin/python /home/apushkov/projects/scud_ai/main_etl.py --skip-export >> /home/apushkov/projects/scud_ai/logs/cron_reports.log 2>&1
echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_reports.log
+266 -48
View File
@@ -7,7 +7,10 @@ from datetime import datetime
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR
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 (
get_connection,
get_available_snapshots,
@@ -15,9 +18,54 @@ from core.database import (
load_scud_from_db_by_snapshot,
get_latest_snapshot_time
)
from services.exceptions_repo import (
get_all_exceptions_from_db,
add_exception_to_db,
remove_exception_from_db,
sync_json_to_db
)
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def cmd_mapping(args_list):
"""Управление подтвержденными сопоставлениями ФИО (СКУД <-> 1С:ЗУП)."""
with get_connection() as conn:
cursor = conn.cursor()
if not args_list or args_list[0] in ["list", "show"]:
cursor.execute("SELECT id, scud_fio, zup_fio, match_source, status FROM person_identity_mapping ORDER BY id DESC")
rows = cursor.fetchall()
print("\n🔗 СОХРАНЕННЫЕ СОПОСТАВЛЕНИЯ ФИО (person_identity_mapping):")
print("=" * 80)
if rows:
for r in rows:
print(f" #{r[0]} [{r[4]}] СКУД: '{r[1]}' ⟷ 1С: '{r[2]}' ({r[3]})")
else:
print(" — сопоставлений пока нет")
print("=" * 80 + "\n")
return
subcmd = args_list[0]
if subcmd == "add":
if len(args_list) < 3:
print("Использование: python scripts/db_cli.py mapping add 'ФИО в СКУД' 'ФИО в 1С'")
return
scud_f, zup_f = args_list[1], args_list[2]
cursor.execute("""
INSERT OR REPLACE INTO person_identity_mapping (scud_fio, zup_fio, match_source, status)
VALUES (?, ?, 'MANUAL', 'ACTIVE')
""", (scud_f.strip(), zup_f.strip()))
conn.commit()
print(f"✅ Успешно добавлена связка: '{scud_f}' ⟷ '{zup_f}'")
elif subcmd in ["del", "delete", "remove"]:
if len(args_list) < 2:
print("Использование: python scripts/db_cli.py mapping del 'ФИО в СКУД'")
return
cursor.execute("DELETE FROM person_identity_mapping WHERE scud_fio = ?", (args_list[1].strip(),))
conn.commit()
print(f"✅ Связка для '{args_list[1]}' удалена.")
def print_tool_actions():
"""Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
print("\n" + "=" * 110)
@@ -25,6 +73,7 @@ def print_tool_actions():
print("=" * 110)
with get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute("""
SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json
FROM tool_action_registry
@@ -42,8 +91,11 @@ def print_tool_actions():
print(f" • Вопрос: {r[5]}")
print(f" • Кнопки: {r[6]}")
print("-" * 110)
except Exception as e:
print(f"Таблица tool_action_registry недоступна: {e}")
print("=" * 110 + "\n")
def print_stats():
"""Выводит общую статистику по записям в таблицах БД."""
print("\n" + "=" * 60)
@@ -51,11 +103,18 @@ def print_stats():
print("=" * 60)
with get_connection() as conn:
cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']
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'
]
for t in tables:
try:
cursor.execute(f"SELECT COUNT(*) FROM {t}")
cnt = cursor.fetchone()[0]
print(f" • Таблица [{t:<18}]: {cnt:>6} записей")
print(f" • Таблица [{t:<22}]: {cnt:>6} записей")
except Exception:
pass
print("=" * 60 + "\n")
@@ -79,13 +138,11 @@ def print_snapshots_list(date_str=None):
def snapshot_sort_key(row):
snap_id = row[0] or ""
snap_time = row[2] or ""
seq_num = 0
if "-" in snap_id:
parts = snap_id.replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
seq_num = int(parts[1])
return (snap_time, seq_num)
sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True)
@@ -101,11 +158,7 @@ def print_snapshots_list(date_str=None):
time_part = snap_time.split(" ")[1]
slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date
if not snap_id.startswith("Y"):
formatted_snap_id = f" {snap_id}"
else:
formatted_snap_id = snap_id
formatted_snap_id = f" {snap_id}" if not snap_id.startswith("Y") else snap_id
print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}")
@@ -138,31 +191,44 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
"""Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX."""
target_date = date_str if date_str else DATE_TODAY
print("\n" + "=" * 90)
print("\n" + "=" * 115)
if snapshot_id:
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):")
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата среза: {target_date}):")
else:
print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):")
print("=" * 90)
print("=" * 115)
df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
if df.empty:
print(f"Записи СКУД не найдены.")
print("=" * 90 + "\n")
if df is None or df.empty:
print("Записи СКУД не найдены.")
print("=" * 115 + "\n")
return
fio_col = next((c for c in ['Сотрудник', 'fio', 'fio_clean'] if c in df.columns), None)
dept_col = next((c for c in ['Подразделение', 'department_scud', 'department'] if c in df.columns), None)
pos_col = next((c for c in ['Должность', 'position'] if c in df.columns), None)
in_col = next((c for c in ['Начало_дня', 'time_in'] if c in df.columns), None)
first_act_col = next((c for c in ['Первая_активность', 'first_activity'] if c in df.columns), None)
out_col = next((c for c in ['Конец_дня', 'time_out'] if c in df.columns), None)
dur_col = next((c for c in ['Находился_в_здании', 'time_in_building', 'duration'] if c in df.columns), None)
present_col = next((c for c in ['Пришел', 'is_present'] if c in df.columns), None)
anom_col = 'anomaly_flag' if 'anomaly_flag' in df.columns else None
snap_col = 'snapshot_id' if 'snapshot_id' in df.columns else None
total = len(df)
present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0
if present_col:
present_cnt = len(df[df[present_col].astype(str).str.lower().isin(['true', '1'])])
else:
present_cnt = 0
absent_cnt = total - present_cnt
print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}")
print("-" * 90)
print(f"Всего записей: {total} | Присутствовали: {present_cnt} | Отсутствовали: {absent_cnt}")
print("-" * 115)
cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id']
existing_cols = [c for c in cols_to_show if c in df.columns]
display_cols = [c for c in [fio_col, dept_col, in_col, first_act_col, out_col, dur_col, present_col, anom_col, snap_col] if c]
print(df[display_cols].head(30).to_string(index=False))
print(df[existing_cols].head(30).to_string(index=False))
if len(df) > 30:
print(f"\n... и ещё {len(df) - 30} строк.")
@@ -172,11 +238,11 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
out_path = os.path.join(OUTPUT_DIR, out_path)
df.to_excel(out_path, index=False)
print("\n" + "*" * 90)
print("\n" + "*" * 115)
print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}")
print("*" * 90)
print("*" * 115)
print("=" * 90 + "\n")
print("=" * 115 + "\n")
def print_absences(date_str=None):
@@ -233,31 +299,31 @@ 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']:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
print(f"\n[🔄] Создание полного дампа БД в файл: {out_filename} ...")
out_path = dump_database_to_excel(out_filename)
print(f"[✓] Дамп успешно сохранен: {out_path}\n")
def print_system_prompts():
"""Выводит все системные промпты из базы данных."""
print("\n" + "=" * 80)
print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):")
print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):")
print("=" * 80)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC")
try:
cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC")
rows = cursor.fetchall()
except Exception:
rows = []
if not rows:
print("Таблица system_prompts пуста.")
print("Таблица system_prompt_nodes пуста.")
else:
for r in rows:
print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}")
print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}")
print(f" {r[2]}")
print("-" * 80)
print(f"{r[4]}\n")
print("=" * 80 + "\n")
@@ -280,7 +346,92 @@ def print_session_states():
print("=" * 80 + "\n")
Python
def print_chat_messages(session_id=None, limit=50):
"""Выводит таблицу истории сообщений чата (контекст)."""
print("\n" + "=" * 105)
print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:")
print("=" * 105)
with get_connection() as conn:
cursor = conn.cursor()
if session_id:
cursor.execute("""
SELECT id, session_id, role, content, is_ephemeral, created_at
FROM chat_messages
WHERE session_id = ?
ORDER BY id ASC
LIMIT ?
""", (session_id, limit))
else:
cursor.execute("""
SELECT id, session_id, role, content, is_ephemeral, created_at
FROM chat_messages
ORDER BY id DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
if not rows:
print("Таблица chat_messages пуста.")
else:
if not session_id:
rows = list(reversed(rows))
for r in rows:
msg_id, sess, role, content, ephemeral, created = r
eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else ""
print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}")
print("-" * 105)
content_preview = content if content else ""
print(f"{content_preview}")
print("=" * 105)
print("\n")
def purge_chat_context(session_id=None, purge_all=False):
"""Очистка контекста сообщений через репозиторий чата."""
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")
def print_exceptions():
"""Выводит реестр исключений и белый список сотрудников из базы SQLite."""
exc = get_all_exceptions_from_db()
print("\n" + "=" * 80)
print("📋 РЕЕСТР ИСКЛЮЧЕНИЙ И БЕЛЫЙ СПИСОК (exceptions_registry):")
print("=" * 80)
for cat, items in exc.items():
print(f"[{cat.upper()}] ({len(items)} шт.):")
if items:
for it in items:
print(f" • {it}")
else:
print(" — пусто")
print("-" * 80)
print("=" * 80 + "\n")
def print_building_presence(date_str: str, all_statuses: bool = False):
"""Выводит оперативный список сотрудников, находящихся в здании."""
records = get_building_presence(date_str, only_inside=not all_statuses)
title = f"КТО СЕЙЧАС В ЗДАНИИ [{date_str}]" if not all_statuses else f"ОПЕРАТИВНЫЙ СТАТУС СОТРУДНИКОВ [{date_str}]"
print("\n" + "=" * 80)
print(f"🏢 {title} (Всего: {len(records)})")
print("=" * 80)
if not records:
print(" Нет данных о проходах за указанную дату.")
else:
print(f"{'ФИО':<35} | {'Подразделение':<15} | {'Время':<10} | {'Статус':<8}")
print("-" * 80)
for r in records:
time_short = r['last_event_time'].split()[-1][:8] if ' ' in r['last_event_time'] else r['last_event_time'][:8]
print(f"{r['fio']:<35} | {r['department'][:15]:<15} | {time_short:<10} | {r['status']:<8}")
print("=" * 80 + "\n")
HELP_TEXT = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
@@ -288,27 +439,44 @@ CLI-утилита инспекции и управления SQLite базой
stats -- Общая статистика строк по всем таблицам БД
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
in_building [ДД.ММ.ГГГГ] [--all] -- Оперативный статус: кто сейчас в здании (или все статусы с флагом --all)
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
prompts -- Посмотреть системные промпты (system_prompts)
prompts -- Посмотреть узлы системного промпта (system_prompt_nodes)
tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry)
sessions -- Посмотреть активные сессии и превью (session_states)
context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages)
context purge [session_id] [--all] -- Очистить контекст (умная зачистка или полная с флагом --all)
dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день
exceptions [list] -- Посмотреть реестр исключений и белый список (SQLite)
exceptions add -c CATEGORY -v VALUE [-m COMMENT] -- Добавить исключение (fio, include_fio, departments, positions, position_keywords)
exceptions del -c CATEGORY -v VALUE -- Удалить исключение из БД
exceptions sync -- Синхронизировать exceptions.json -> SQLite
ПРИМЕРЫ ЗАПУСКА:
python scripts/db_cli.py stats
python scripts/db_cli.py snapshots 06.08.2026
python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
python scripts/db_cli.py scud --snapshot Y20260805-007
python scripts/db_cli.py in_building 07.09.2026
python scripts/db_cli.py in_building 07.09.2026 --all
python scripts/db_cli.py absences 07.08.2026
python scripts/db_cli.py prompts
python scripts/db_cli.py tools
python scripts/db_cli.py sessions
python scripts/db_cli.py context web_session_main --limit 20
python scripts/db_cli.py context purge
python scripts/db_cli.py context purge --all
python scripts/db_cli.py context purge web_session_main --all
python scripts/db_cli.py snapshot del Y20260805-007
python scripts/db_cli.py snapshot del --day 04.08.2026
python scripts/db_cli.py dump my_dump.xlsx
python scripts/db_cli.py exceptions
python scripts/db_cli.py exceptions add -c include_fio -v "Тарасенко Александр Александрович"
python scripts/db_cli.py exceptions add -c fio -v "Михалев Сергей Геннадьевич" -m "Уборщик"
python scripts/db_cli.py exceptions del -c fio -v "Михалев Сергей Геннадьевич"
python scripts/db_cli.py exceptions sync
"""
@@ -322,12 +490,25 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False
)
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools'], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)")
parser.add_argument('command', nargs='?', default=None, choices=[
'stats', 'snapshots', 'scud', 'absences', 'anomalies',
'rules', 'prompts', 'sessions', 'dump', 'snapshot',
'tools', 'context', 'in_building', 'exceptions'
], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Действие ('del', 'purge', 'add', 'sync') или дата/сессия")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, имя файла)")
parser.add_argument('-c', '--category', type=str, default=None, choices=[
'departments', 'positions', 'fio', 'position_keywords',
'include_fio', 'turnstile_fio', 'turnstile_departments',
'fligel_fio', 'fligel_departments'
], help="Категория исключения")
parser.add_argument('-v', '--value', type=str, default=None, help="Значение исключения (ФИО, отдел, должность)")
parser.add_argument('-m', '--comment', type=str, default="", help="Комментарий к исключению")
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)")
parser.add_argument('--all', action='store_true', help="Полная очистка всех сообщений (для команды context purge)")
if len(sys.argv) == 1:
print_stats()
@@ -338,12 +519,13 @@ def main():
if args.command == 'stats':
print_stats()
elif args.command == 'snapshots':
date_val = args.param or args.action
date_val = args.action or args.param
print_snapshots_list(date_str=date_val)
elif args.command == 'scud':
inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx)
date_val = args.action or args.param
inspect_scud(snapshot_id=args.snapshot, date_str=date_val, export_xlsx=args.export_xlsx)
elif args.command == 'absences':
date_val = args.param or args.action
date_val = args.action or args.param
print_absences(date_str=date_val)
elif args.command == 'anomalies':
print_anomalies()
@@ -355,8 +537,19 @@ def main():
print_session_states()
elif args.command == 'tools':
print_tool_actions()
elif args.command in ('in_building', 'presence'):
target_date = args.action if args.action else datetime.now().strftime("%d.%m.%Y")
print_building_presence(target_date, all_statuses=args.all)
elif args.command == 'context':
if args.action in ['purge', 'clear']:
is_all = args.all or (args.param == '--all')
sess_id = None if (args.param == '--all' or not args.param) else args.param
purge_chat_context(session_id=sess_id, purge_all=is_all)
else:
sess_id = args.action if args.action else None
print_chat_messages(session_id=sess_id, limit=args.limit)
elif args.command == 'dump':
filename = args.param if args.param else "db_dump_full.xlsx"
filename = args.action or args.param or "db_dump_full.xlsx"
dump_all_to_excel(filename)
elif args.command == 'snapshot':
if args.action == 'del':
@@ -370,9 +563,34 @@ def main():
else:
print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
elif args.command == 'exceptions':
if args.action == 'add':
if not args.category or not args.value:
print("\n[❌] Ошибка: Для добавления исключения укажите флаги -c/--category и -v/--value")
print("Пример: python scripts/db_cli.py exceptions add -c include_fio -v \"Тарасенко Александр Александрович\"\n")
return
if add_exception_to_db(args.category, args.value, args.comment):
print(f"\n[✓] Успешно добавлено исключение: [{args.category}] {args.value}\n")
else:
print(f"\n[❌] Ошибка: Неизвестная команда.")
print(f"\n[❌] Ошибка добавления исключения [{args.category}] {args.value}\n")
elif args.action in ['del', 'delete', 'remove']:
if not args.category or not args.value:
print("\n[❌] Ошибка: Для удаления исключения укажите флаги -c/--category и -v/--value")
print("Пример: python scripts/db_cli.py exceptions del -c fio -v \"Михалев Сергей Геннадьевич\"\n")
return
if remove_exception_from_db(args.category, args.value):
print(f"\n[✓] Успешно удалено исключение: [{args.category}] {args.value}\n")
else:
print(f"\n[⚠️] Запись не найдена в базе: [{args.category}] {args.value}\n")
elif args.action == 'sync':
sync_json_to_db()
print("\n[✓] Синхронизация exceptions.json -> SQLite успешно завершена.\n")
else:
print_exceptions()
else:
print("\n[❌] Ошибка: Неизвестная команда.")
print(HELP_TEXT)
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
import os
import sqlite3
DB_PATH = os.path.join(os.path.dirname(__file__), "../../data/scud_orion_ai.db")
if not os.path.exists(DB_PATH):
DB_PATH = "data/scud_orion_ai.db"
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
print("=" * 90)
print(f"🔍 ИНСПЕКЦИЯ СООБЩЕНИЙ В БАЗЕ: {DB_PATH}")
print("=" * 90)
# 1. Проверяем наличие колонки is_ephemeral
cursor.execute("PRAGMA table_info(chat_messages);")
cols = [c[1] for c in cursor.fetchall()]
if "is_ephemeral" not in cols:
print("❌ Колонка is_ephemeral отсутствует в таблице chat_messages!")
conn.close()
exit(1)
# 2. Считаем общее количество и распределение
cursor.execute("SELECT COUNT(*) FROM chat_messages")
total_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 1")
ephemeral_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 0")
permanent_count = cursor.fetchone()[0]
print(f"Всего записей в истории: {total_count}")
print(f" • Постоянные (is_ephemeral = 0): {permanent_count}")
print(f" • Фантомные / Эфемерные (is_ephemeral = 1): {ephemeral_count}")
print("-" * 90)
# 3. Выводим все фантомные записи
if ephemeral_count == 0:
print("В базе нет фантомных записей (is_ephemeral = 1 пуст).")
else:
print(f"📋 СПИСОК ВСЕХ ФАНТОМНЫХ СООБЩЕНИЙ ({ephemeral_count} шт.):")
cursor.execute("""
SELECT id, session_id, role, created_at, content
FROM chat_messages
WHERE is_ephemeral = 1
ORDER BY id ASC
""")
for row in cursor.fetchall():
msg_id, session_id, role, created_at, content = row
print(f"\n[ID: {msg_id}] | Сессия: {session_id} | Роль: {role} | Время: {created_at}")
print("Содержимое:")
print(content.strip())
print("-" * 70)
conn.close()
print("=" * 90)
-31
View File
@@ -1,31 +0,0 @@
import os
OUTPUT_SNAPSHOT = "project_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
===============================================================================
FILE: scripts/diagnostics/make_etl_snapshot.py
ROLE: Компактная динамическая генерация слепка ETL-конвейера, сервисов и БД.
Исключает исторические манифесты docs, диагностический шум и пустые файлы.
===============================================================================
"""
import os
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
OUTPUT_FILE = os.path.join(ROOT_DIR, "etl_code_snapshot.md")
# 1. Отдельные ключевые файлы в корне проекта
ROOT_EXPLICIT_FILES = [
"config.py",
"exceptions.json",
"main_etl.py"
]
# 2. Директории для автоматического сканирования
INCLUDED_DIRS = [
"core",
"services",
"scripts",
"docs"
]
# 3. Разрешенные расширения файлов
ALLOWED_EXTENSIONS = {
".py": "py",
".sh": "bash",
".json": "json",
".md": "markdown"
}
# 4. Папки, которые категорически игнорируются
IGNORE_DIRS = {
"venv", ".venv", ".git", "__pycache__", "data", "output", "logs",
"node_modules", "static", ".idea", ".vscode", "diagnostics"
}
# 5. Файлы, исключаемые для экономии контекста (тяжелые исторические манифесты и временные дампы)
IGNORE_FILES = {
"etl_code_snapshot.md",
"web_api_code_snapshot.md",
"db_dump_full.xlsx",
# Исключаем исторические манифесты из docs/ (~60 КБ дублирующего текста)
"PROJECT BRAIN_ SCUD Orion AI & Context API (Master Manifesto v5.0).md",
"SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт и технический контекст (v4.0).md"
}
def collect_target_files():
"""Автоматически собирает список файлов проекта без шума и устаревших манифестов."""
target_files = []
# Добавляем ключевые файлы из корня
for fname in ROOT_EXPLICIT_FILES:
fpath = os.path.join(ROOT_DIR, fname)
if os.path.isfile(fpath):
target_files.append(fname)
# Рекурсивный обход разрешенных каталогов
for d_name in INCLUDED_DIRS:
base_dir = os.path.join(ROOT_DIR, d_name)
if not os.path.exists(base_dir):
continue
for root, dirs, files in os.walk(base_dir):
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
for file in sorted(files):
if file in IGNORE_FILES or file.startswith("."):
continue
_, ext = os.path.splitext(file)
if ext.lower() in ALLOWED_EXTENSIONS:
full_path = os.path.join(root, file)
# Пропускаем пустые __init__.py (0 байт)
if file == "__init__.py" and os.path.getsize(full_path) == 0:
continue
rel_path = os.path.relpath(full_path, ROOT_DIR)
target_files.append(rel_path)
return sorted(target_files)
def create_etl_snapshot():
files_to_pack = collect_target_files()
content = ["# 📦 КОМПАКТНЫЙ ETL-СЛЕПОК ИСХОДНОГО КОДА (СКУД ⟷ 1С & DB CORE)\n"]
included_count = 0
for rel_path in files_to_pack:
full_path = os.path.join(ROOT_DIR, rel_path)
ext = os.path.splitext(rel_path)[1].lower()
lang = ALLOWED_EXTENSIONS.get(ext, "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(content))
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
print(f"\n[✓] Компактный ETL-слепок успешно создан: {OUTPUT_FILE}")
print(f" Включено файлов: {included_count} | Размер: {size_kb:.1f} KB\n")
if __name__ == "__main__":
create_etl_snapshot()
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
===============================================================================
FILE: scripts/diagnostics/make_web_snapshot.py
ROLE: Динамическая автоматическая генерация слепка Web API, фронтенда и LLM.
Исключает временный кэш, авто-обнаруживает новые модули sidebar и роутеры.
===============================================================================
"""
import os
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
WEB_API_DIR = os.path.join(ROOT_DIR, "modules", "web_api")
OUTPUT_FILE = os.path.join(ROOT_DIR, "web_api_code_snapshot.md")
ALLOWED_EXTENSIONS = {
".py": "py",
".js": "js",
".html": "html",
".css": "css",
".json": "json"
}
IGNORE_DIRS = {
"__pycache__", ".git", "venv", ".venv", "uploads", "logs",
"node_modules", ".idea", ".vscode"
}
IGNORE_FILES = {
"web_api_code_snapshot.md",
"favicon.ico"
}
def collect_web_files():
"""Рекурсивно собирает все исходники web_api без необходимости хардкода."""
target_files = []
if not os.path.exists(WEB_API_DIR):
print(f"[❌] Директория не найдена: {WEB_API_DIR}")
return []
for root, dirs, files in os.walk(WEB_API_DIR):
# Исключаем служебные папки
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
for file in sorted(files):
if file in IGNORE_FILES or file.startswith("."):
continue
_, ext = os.path.splitext(file)
if ext.lower() in ALLOWED_EXTENSIONS:
full_path = os.path.join(root, file)
# Пропускаем пустые __init__.py (0 байт)
if file == "__init__.py" and os.path.getsize(full_path) == 0:
continue
rel_path = os.path.relpath(full_path, ROOT_DIR)
target_files.append(rel_path)
return sorted(target_files)
def create_web_snapshot():
files_to_pack = collect_web_files()
content = ["# 🌐 WEB API & FRONTEND CODE SNAPSHOT (AUTO-DISCOVERY)\n"]
included_count = 0
for rel_path in files_to_pack:
full_path = os.path.join(ROOT_DIR, rel_path)
ext = os.path.splitext(rel_path)[1].lower()
lang = ALLOWED_EXTENSIONS.get(ext, "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(content))
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
print(f"\n[✓] Web API + Фронтенд слепок успешно создан: {OUTPUT_FILE}")
print(f" Включено файлов: {included_count} | Размер: {size_kb:.1f} KB\n")
if __name__ == "__main__":
create_web_snapshot()
@@ -0,0 +1,22 @@
import os
OUTPUT_SNAPSHOT = "web_api_code_snapshot.md"
ALLOWED_EXTENSIONS = {'.py', '.html', '.css', '.js'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project', 'docs', 'data'}
print(f"🔄 Сборка Web API слепка кода в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 WEB API & AI СЛЕПОК ИСХОДНОГО КОДА\n\n")
if os.path.exists('modules'):
for r, d, files in os.walk('modules'):
d[:] = [sub for sub in d if sub not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS:
filepath = os.path.join(r, file)
out.write(f"## File: `./{filepath}`\n```" + ext.replace('.', '') + "\n")
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
out.write("\n```\n\n")
print(f"✓ Web API слепок готов: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT) / 1024:.1f} KB)")
+29 -10
View File
@@ -151,7 +151,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
if not unrecognized_scud_fios or not staff_fios:
return {}
# 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен)
# Исключаем точные совпадения
staff_fios_set = set(staff_fios)
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
@@ -163,7 +163,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
prompt = f"""
Ты — кадровый аудитор безопасности СКУД.
{rules_context}
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)}
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
СТРОГИЕ ПРАВИЛА:
@@ -173,39 +173,58 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
4. В поле "warning" опиши обнаруженную опечатку.
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
Формат ответа:
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
{{
"verified_matches": [
{{
"scud_fio": "ФИО из СКУД",
"staff_fio": "эталон ФИО из Штат 1С",
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
"warning": "Описание опечатки в СКУД"
}}
]
}}
"""
raw_response = ask_ollama(
prompt,
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
)
mapping = {}
if not raw_response:
return mapping
# Фаза 1: Попытка прямого разбора с санитарной очисткой
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
json_str = match.group(0)
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
# Убираем висячие запятые: {"a": 1,} -> {"a": 1}
json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
# Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
data = json.loads(json_str)
for item in data.get("verified_matches", []):
scud_f = item.get("scud_fio")
staff_f = item.get("staff_fio")
warn = item.get("warning", "Точное совпадение (без опечаток)")
if scud_f and staff_f:
if scud_f and staff_f and staff_f in staff_fios_set:
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
return mapping
except Exception:
pass
# Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть)
try:
pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']'
matches = re.findall(pattern, raw_response, re.DOTALL)
for scud_f, staff_f in matches:
scud_clean = scud_f.strip()
staff_clean = staff_f.strip()
if staff_clean in staff_fios_set:
mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"}
except Exception as e:
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
print(f"[!] Ошибка резервного парсинга опечаток: {e}")
return mapping
+146 -165
View File
@@ -1,11 +1,18 @@
"""
===============================================================================
FILE: services/data_loader.py
ROLE: Надежная загрузка штата и отсутствий (MS SQL ЗУП -> Резервный Excel).
===============================================================================
"""
import os
import pandas as pd
import warnings
from datetime import datetime # <-- ДОБАВИТЬ ЭТУ СТРОКУ
import pandas as pd
from config import (
normalize_fio, clean_scud_fio_light, load_exceptions,
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR, DATA_DIR
)
# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite
from services.zup_extractor import fetch_zup_absences_from_sql
from core.database import (
load_scud_from_db_by_snapshot,
@@ -16,23 +23,8 @@ from core.database import (
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
def is_excluded(fio, dept, pos, exceptions):
"""Проверяет, входит ли сотрудник или должность в список исключений."""
fio_clean = normalize_fio(fio)
dept_str = str(dept).strip().upper() if pd.notna(dept) else ""
pos_str = str(pos).strip().lower() if pd.notna(pos) else ""
if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]:
return True
if any(keyword in pos_str for keyword in exceptions["position_keywords"]):
return True
return False
def load_staff_data(date_str):
"""
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
"""
"""Загружает файл Штатного расписания 1С."""
filepath = find_dated_file("Штат", date_str)
if not filepath:
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
@@ -43,8 +35,6 @@ def load_staff_data(date_str):
try:
df_raw = pd.read_excel(filepath, skiprows=8)
# Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность
df_staff = df_raw.iloc[:, [1, 5, 12]].copy()
df_staff.columns = ['ФИО', 'Подразделение', 'Должность']
@@ -57,58 +47,168 @@ def load_staff_data(date_str):
return None
def load_absent_from_excel(date_str):
"""Резервное чтение файла Отсутствия_ДД_ММ_ГГГГ.xlsx."""
filepath = find_dated_file("Отсутствия", date_str)
if not filepath:
return None
try:
for skip in [0, 1, 2, 3, 4, 5, 8]:
df_try = pd.read_excel(filepath, skiprows=skip)
fio_col = None
reason_col = None
for col in df_try.columns:
c_str = str(col).lower()
if ('фио' in c_str or 'сотрудник' in c_str) and fio_col is None:
fio_col = col
if ('вид' in c_str or 'причина' in c_str or 'отсутств' in c_str) and reason_col is None:
reason_col = col
if fio_col and reason_col:
df_res = df_try[[fio_col, reason_col]].copy()
df_res.columns = ['ФИО', 'Вид_отсутствия']
df_res = df_res.dropna(subset=['ФИО', 'Вид_отсутствия'])
df_res = df_res[~df_res['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО|ЛЕНМОРНИИПРОЕКТ', case=False, na=False)]
df_res['fio_clean'] = df_res['ФИО'].apply(normalize_fio)
print(f" [✓] Резервный Excel: загружено {len(df_res)} записей отсутствий из {os.path.basename(filepath)}")
return df_res[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
except Exception as e:
print(f" [⚠️] Ошибка чтения резервного Excel отсутствий {filepath}: {e}")
return None
def load_absent_data(date_str):
"""
Загружает документально подтвержденные отсутствия сотрудников
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату,
а также автоматически обогащает их удаленщиками из static_reason_workers.csv.
Загружает отсутствия из MS SQL 1С:ЗУП, а при сбое — из локального Excel.
"""
df_absent = None
try:
df_absent = fetch_zup_absences_from_sql(date_str)
if df_absent is not None and not df_absent.empty:
print(f" [✓] MS SQL ЗУП: получено {len(df_absent)} записей отсутствий за {date_str}")
df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия'])
df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)]
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
else:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
except Exception as e:
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
print(f" [⚠️] Ошибка подключения к MS SQL ЗУП за {date_str}: {e}")
# Резервный источник: Excel файл из 1С с сетевой шары
if df_absent is None or df_absent.empty:
df_absent = load_absent_from_excel(date_str)
if df_absent is None:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
# ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv
# Обогащение удаленщиками из static_reason_workers.csv с ротацией просроченных записей
try:
from config import DATA_DIR
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if os.path.exists(static_path):
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
df_static = pd.read_csv(static_path, dtype=str, on_bad_lines='skip').fillna("")
for col in ['fio', 'reason', 'department', 'date_from', 'date_to']:
if col not in df_static.columns:
df_static[col] = ""
today_date = datetime.now().date()
# Разбор целевой даты отчета
clean_target_str = str(date_str).replace('_', '.')
try:
clean_target_date = datetime.strptime(clean_target_str, "%d.%m.%Y").date()
except ValueError:
clean_target_date = today_date
def parse_date_safe(d_val):
if not d_val or str(d_val).lower() in ['nan', 'none', '', 'nat']:
return None
s = str(d_val).strip().replace('_', '.')
for fmt in ("%d.%m.%Y", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
pass
return None
active_for_file = []
new_rows_for_report = []
file_changed = False
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
new_rows = []
for _, s_row in df_static.iterrows():
if s_row['fio_clean'] not in existing_fios:
new_rows.append({
'fio_clean': s_row['fio_clean'],
'Вид_отсутствия': s_row['reason']
fio_raw = str(s_row.get('fio', '')).strip()
if not fio_raw:
continue
fio_c = normalize_fio(fio_raw)
reason = str(s_row.get('reason', '')).strip() or "Дистанционная работа"
d_from = parse_date_safe(s_row.get('date_from'))
d_to = parse_date_safe(s_row.get('date_to'))
# 1. Физическая ротация просроченных: только если текущий реальный день (today) строго больше date_to
if d_to is not None and today_date > d_to:
print(f" [🧹] Удаленка истекла: {fio_raw} (до {d_to.strftime('%d.%m.%Y')}). Удалена из CSV.")
file_changed = True
continue
active_for_file.append(s_row.to_dict())
# 2. Проверка действия удаленки на дату формируемого отчета:
# Если date_from не указана — действует всегда до date_to
is_after_start = (d_from is None) or (clean_target_date >= d_from)
is_before_end = (d_to is None) or (clean_target_date <= d_to)
if is_after_start and is_before_end:
if fio_c not in existing_fios:
new_rows_for_report.append({
'fio_clean': fio_c,
'Вид_отсутствия': reason
})
if new_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
existing_fios.add(fio_c)
# Перезаписываем CSV только если реально были удалены просроченные сотрудники
if file_changed:
pd.DataFrame(active_for_file).to_csv(static_path, index=False, encoding='utf-8')
print(" [✓] Файл static_reason_workers.csv синхронизирован без просроченных записей.")
if new_rows_for_report:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows_for_report)], ignore_index=True)
print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows_for_report)} чел. в отчет за {date_str}")
except Exception as e:
print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}")
print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}")
# Обогащение реестрами "Мест. командир." и "Иное"
try:
from services.manual_absences_repo import get_active_manual_absences_for_date
manual_records = get_active_manual_absences_for_date(date_str)
if manual_records:
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
manual_rows = []
for r in manual_records:
fc = r['fio_clean']
if fc not in existing_fios:
label = "Мест. командир." if r['absence_type'] == 'LOCAL_TRIP' else "Иное"
manual_rows.append({
'fio_clean': fc,
'Вид_отсутствия': label
})
existing_fios.add(fc)
if manual_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(manual_rows)], ignore_index=True)
print(f" [✓] Реестры 'Мест. командир.' / 'Иное': добавлено {len(manual_rows)} чел. в отчет за {date_str}")
except Exception as e:
print(f" [⚠️] Ошибка применения manual_absences: {e}")
return df_absent if not df_absent.empty else None
def load_1c_data_smart(date_str, use_db=False):
"""
Универсальный загрузчик данных 1С (Штат и Отсутствия).
Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД
за целевую дату указанного снапшота.
"""
df_staff = None
df_absent = None
@@ -116,128 +216,9 @@ def load_1c_data_smart(date_str, use_db=False):
df_staff = load_staff_from_db(date_str)
df_absent = load_absences_from_db(date_str)
# Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL
if df_staff is None:
df_staff = load_staff_data(date_str)
if df_absent is None:
df_absent = load_absent_data(date_str)
return df_staff, df_absent
def process_scud_anomalies(df_scud, exceptions=None):
"""
Вычисляет 'Первую активность' и маркирует аномалии СКУД.
"""
if exceptions is None:
exceptions = load_exceptions()
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None)
status_col = 'Статус' if 'Статус' in df_scud.columns else None
first_activities = []
anomaly_flags = []
is_present_flags = []
for _, row in df_scud.iterrows():
fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip()
fio_clean = normalize_fio(fio)
dept = str(row.get('Подразделение', '')).strip()
pos = str(row.get('Должность', '')).strip()
val_start = str(row.get(start_col, '')).strip() if start_col else ''
val_end = str(row.get(end_col, '')).strip() if end_col else ''
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
raw_first_event = row.get('raw_first_event', None)
if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']:
raw_first_event = None
has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00'])
has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00'])
first_act = '—'
anom_flag = 'NONE'
is_present = not ('отсутствовал' in val_status or has_no_in)
if has_no_in:
if raw_first_event is not None or not has_no_out:
first_act = str(raw_first_event) if raw_first_event else val_end
anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY'
if not is_excluded(fio, dept, pos, exceptions):
is_present = False
else:
first_act = '—'
is_present = False
first_activities.append(first_act)
anomaly_flags.append(anom_flag)
is_present_flags.append(is_present)
df_scud['Первая_активность'] = first_activities
df_scud['anomaly_flag'] = anomaly_flags
df_scud['Пришел'] = is_present_flags
return df_scud
def load_scud_data(target_date_type="today", snapshot_param=None):
"""
Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite.
"""
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.")
return pd.DataFrame()
exceptions = load_exceptions()
if 'fio_clean' not in df_scud.columns:
fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0])
df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str)
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns:
df_scud['Начало_дня'] = df_scud['time_in']
if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns:
df_scud['Конец_дня'] = df_scud['time_out']
if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns:
df_scud['Первая_активность'] = df_scud['first_activity']
if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns:
df_scud['Пришел'] = df_scud['is_present'].astype(bool)
df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions),
axis=1
)
return df_scud
def load_all_data():
"""
Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С.
"""
df_scud_today = load_scud_data(target_date_type="today")
try:
df_scud_yesterday = load_scud_data(target_date_type="yesterday")
except FileNotFoundError:
df_scud_yesterday = df_scud_today.copy()
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY)
return (
df_scud_today,
df_scud_yesterday,
df_staff_today,
df_absent_today,
df_staff_yesterday,
df_absent_yesterday
)
+19 -401
View File
@@ -1,401 +1,19 @@
import math
import os
import time
import openpyxl
import pandas as pd
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from config import OUTPUT_DIR
# Словарь месяцев для текстового формата в названиях файлов
MONTHS_RU = {
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
5: "мая", 6: "июня", 7: "июля", 8: "августа",
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
}
def format_date_ru(date_str):
"""Преобразует дату формата '27.07.2026' в '27 июля 2026'"""
try:
dt = datetime.strptime(date_str, "%d.%m.%Y")
return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}"
except Exception:
return date_str
# Границы ячеек
THIN_SIDE = Side(border_style="thin", color="D3D3D3")
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE)
# Палитра заливки для Сводки
FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска"
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"]
# Палитра для Детального отчета
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске)
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска"
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
"""Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин"""
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
return "0:00"
if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']:
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 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 f"-{norm_hours}:00"
def apply_borders_to_cell(cell, border=THIN_BORDER):
cell.border = border
def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False):
cell_a = ws.cell(row=r_num, column=1)
cell_b = ws.cell(row=r_num, column=2)
if fill_obj:
cell_a.fill = fill_obj
cell_b.fill = fill_obj
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
if is_bold and bold_font:
cell_a.font = bold_font
cell_b.font = bold_font
if align_b:
cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
# --- 1. СВОДКА НА СЕГОДНЯ ---
def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
if not filename:
filename = f"{format_date_ru(date_str)} сводка.xlsx"
output_path = os.path.join(OUTPUT_DIR, filename)
wb = Workbook()
ws = wb.active
ws.title = "Лист_1"
ws.sheet_properties.outlinePr.summaryBelow = False
ws.sheet_properties.outlinePr.summaryRight = False
ws.sheet_properties.outlinePr.showOutlineSymbols = True
ws.sheet_view.showOutlineSymbols = True
bold_font = Font(name="Calibri", size=11, bold=True)
# 1. Шапка
ws.cell(row=1, column=1, value="Сводка на")
ws.cell(row=1, column=2, value=date_str)
format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font)
apply_borders_to_cell(ws.cell(row=2, column=1))
apply_borders_to_cell(ws.cell(row=2, column=2))
# 2. По списку
ws.cell(row=3, column=1, value="По списку")
ws.cell(row=3, column=2, value=len(merged_df))
format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font)
current_row = 4
# Подготовка флагов для точной фильтрации
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
is_exc = merged_df.get('is_excluded', False) == True
# 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана")
unexplained = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass) &
(~is_exc)
]
ws.cell(row=current_row, column=1, value="неизвестно")
ws.cell(row=current_row, column=2, value=len(unexplained))
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False
current_row += 1
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок)
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
ws.cell(row=current_row, column=1, value="Нет пропуска")
ws.cell(row=current_row, column=2, value=len(no_pass_df))
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font)
current_row += 1
if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False
current_row += 1
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
absent_only = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_groups = absent_only.groupby('Вид_отсутствия')
for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)]
cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid")
ws.cell(row=current_row, column=1, value=cat_name)
ws.cell(row=current_row, column=2, value=len(group))
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font)
current_row += 1
for fio in sorted(group['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, cat_fill, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
)
present = merged_df[is_working_mask]
ws.cell(row=current_row, column=1, value="Итого на работе")
ws.cell(row=current_row, column=2, value=len(present))
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
current_row += 1
if not present.empty:
for fio in sorted(present['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
anomalies = merged_df[
(~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) |
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
)
]
ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С")
ws.cell(row=current_row, column=2, value=len(anomalies))
format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font)
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}"
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY
cell_b.fill = FILL_ANOMALY
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center")
if len(reason_text) > chars_per_line_b:
lines_count = math.ceil(len(reason_text) / chars_per_line_b)
ws.row_dimensions[current_row].height = max(lines_count * 18, 22)
else:
ws.row_dimensions[current_row].height = 20
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
ws.column_dimensions['A'].width = 45.0
ws.column_dimensions['B'].width = 38.0
try:
wb.save(output_path)
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except PermissionError:
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
if not filename:
filename = f"{format_date_ru(date_str)} отчет.xlsx"
output_path = os.path.join(OUTPUT_DIR, filename)
wb = Workbook()
ws = wb.active
ws.title = "Детальный_отчет"
ws["B2"] = "Дата:"
ws["D2"] = date_str
ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True)
headers = [
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
]
ws.append([])
ws.append(headers)
header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=4, column=col_idx)
cell.fill = header_fill
cell.font = Font(name="Arial", size=10, bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
apply_borders_to_cell(cell)
start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня'
end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня'
hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании'
chars_per_line_h = 24
for idx, row in merged_df.reset_index(drop=True).iterrows():
is_present = row.get('Пришел', False)
absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
anom_flag = row.get('anomaly_flag', 'NONE')
in_building_str = str(row.get(hours_col, '00:00'))
first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
ws.append([
idx + 1,
row.get('Сотрудник', ''),
dept_scud_val,
row.get(start_col, 'Нет входа'),
first_act_val,
row.get(end_col, 'Нет выхода'),
in_building_str,
absence_reason if has_reason else '',
8,
deviation_val
])
row_num = 5 + idx
# ЗАЛИВКА СТРОК:
if is_present and has_reason:
row_fill = GREEN_FILL
elif not is_present and has_reason:
row_fill = YELLOW_FILL
elif not is_present and not has_reason and not has_first_act:
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы)
row_fill = LIGHT_RED_FILL
else:
row_fill = None
val_h_str = str(absence_reason) if has_reason else ""
if len(val_h_str) > chars_per_line_h:
needed_lines = math.ceil(len(val_h_str) / chars_per_line_h)
ws.row_dimensions[row_num].height = max(needed_lines * 18, 22)
else:
ws.row_dimensions[row_num].height = 20
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=row_num, column=col_idx)
apply_borders_to_cell(cell)
if row_fill:
cell.fill = row_fill
if col_idx == 8:
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
elif col_idx in [1, 4, 5, 6, 7, 9, 10]:
cell.alignment = Alignment(horizontal="center", vertical="center")
else:
cell.alignment = Alignment(horizontal="left", vertical="center")
# Динамический компактный автоподгон ширины колонок
for col in ws.columns:
col_letter = get_column_letter(col[0].column)
max_len = 0
for cell in col:
if cell.value is not None:
cell_lines = str(cell.value).split("\n")
line_max = max(len(line) for line in cell_lines)
if line_max > max_len:
max_len = line_max
optimal_width = max(max_len + 2, 8)
if optimal_width > 35:
optimal_width = 35
ws.column_dimensions[col_letter].width = optimal_width
try:
wb.save(output_path)
print(f"[✓] Детальный отчет сохранен: {output_path}")
except PermissionError:
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
output_path = os.path.join(OUTPUT_DIR, filename)
df_scud.to_excel(output_path, index=False)
"""
===============================================================================
FILE: services/excel_exporter.py
ROLE: Единый фасад генераторов Excel-отчетов (обратная совместимость).
===============================================================================
"""
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
+94
View File
@@ -0,0 +1,94 @@
"""
===============================================================================
FILE: services/exceptions_repo.py
ROLE: Управление исключениями в SQLite с синхронизацией с exceptions.json.
===============================================================================
"""
from typing import Dict, List, Any
import json
import os
from config import EXCEPTIONS_PATH, normalize_fio
from core.connection import get_connection
def init_exceptions_table():
with get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS exceptions_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
value TEXT NOT NULL,
comment TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category, value)
);
""")
conn.commit()
def get_all_exceptions_from_db() -> Dict[str, List[str]]:
init_exceptions_table()
cfg = {
"departments": [],
"positions": [],
"fio": [],
"position_keywords": [],
"include_fio": [],
"turnstile_fio": [],
"turnstile_departments": [],
"fligel_fio": [], # ⭐️ Сотрудники с доступом через Флигель (DoorIndex = 23)
"fligel_departments": [] # ⭐️ Отделы с доступом через Флигель
}
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT category, value FROM exceptions_registry")
rows = cursor.fetchall()
if not rows and os.path.exists(EXCEPTIONS_PATH):
sync_json_to_db()
return get_all_exceptions_from_db()
for cat, val in rows:
if cat in cfg:
cfg[cat].append(val)
return cfg
def add_exception_to_db(category: str, value: str, comment: str = "") -> bool:
init_exceptions_table()
# Нормализуем ФИО в том числе для реестра флигеля
val_clean = normalize_fio(value) if category in ["fio", "include_fio", "turnstile_fio", "fligel_fio"] else value.strip()
if not val_clean:
return False
with get_connection() as conn:
conn.execute(
"INSERT OR REPLACE INTO exceptions_registry (category, value, comment) VALUES (?, ?, ?)",
(category, val_clean, comment)
)
conn.commit()
return True
def remove_exception_from_db(category: str, value: str) -> bool:
init_exceptions_table()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM exceptions_registry WHERE category = ? AND value = ?", (category, value.strip()))
conn.commit()
return cursor.rowcount > 0
def sync_json_to_db():
"""Переносит данные из exceptions.json в SQLite."""
if not os.path.exists(EXCEPTIONS_PATH):
return
try:
with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
for cat, items in data.items():
for item in items:
add_exception_to_db(cat, item, comment="Импорт из JSON")
except Exception as e:
print(f"[⚠️] Ошибка синхронизации JSON -> DB: {e}")
View File
+37
View File
@@ -0,0 +1,37 @@
"""
===============================================================================
FILE: services/knowledge/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / knowledge
ROLE: Доменный сервис базы знаний, правил компании и синонимов подразделений.
===============================================================================
"""
from typing import List, Dict, Any
from core.repositories.zup_repo import (
get_all_rules_from_db,
add_rule_to_db,
get_department_synonyms_dict,
add_department_synonym_to_db
)
def get_rules() -> List[Dict[str, Any]]:
"""Получить все правила базы знаний в виде списка словарей."""
raw_rules = get_all_rules_from_db()
return [{"id": idx, "rule_text": r} for idx, r in enumerate(raw_rules, 1)]
def add_rule(rule_text: str, added_by: str = "Human") -> None:
"""Добавить новое правило в базу знаний."""
add_rule_to_db(rule_text, added_by=added_by)
def get_synonyms() -> Dict[str, str]:
"""Получить словарь синонимов отделов."""
return get_department_synonyms_dict()
def register_department_synonym(short_name: str, full_name: str) -> None:
"""Сохранить новую пару синонимов подразделения."""
add_department_synonym_to_db(short_name, full_name)
+222
View File
@@ -0,0 +1,222 @@
"""
===============================================================================
FILE: services/manual_absences_repo.py
ROLE: Репозиторий ручных реестров ("Мест. командир.", "Иное") и поиск по штату 1С.
===============================================================================
"""
import os
import csv
from datetime import datetime
from typing import List, Dict, Any, Optional
from core.connection import get_connection
from config import normalize_fio, DATA_DIR
REASONS_CSV_PATH = os.path.join(DATA_DIR, "static_reason_absence.csv")
def init_manual_absences_table() -> None:
with get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS manual_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_type TEXT NOT NULL, -- 'LOCAL_TRIP' или 'OTHER'
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT DEFAULT '',
position TEXT DEFAULT '',
date_start TEXT, -- 'YYYY-MM-DD'
date_end TEXT, -- 'YYYY-MM-DD'
reason TEXT NOT NULL,
comment TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_abs_dates ON manual_absences(date_start, date_end);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_abs_fio ON manual_absences(fio_clean);")
conn.commit()
def get_static_reasons() -> List[str]:
"""Возвращает список причин из data/static_reason_absence.csv."""
if not os.path.exists(REASONS_CSV_PATH):
# Если файл еще не создан, создаем базовый набор причин
os.makedirs(os.path.dirname(REASONS_CSV_PATH), exist_ok=True)
default_reasons = ["По семейным обстоятельствам", "Медосмотр", "Сдача крови", "Учебный отпуск", "Административный отпуск"]
with open(REASONS_CSV_PATH, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["reason"])
for r in default_reasons:
writer.writerow([r])
return default_reasons
reasons = []
try:
with open(REASONS_CSV_PATH, "r", encoding="utf-8") as f:
reader = csv.reader(f)
for row in reader:
if row and row[0].strip() and row[0].strip().lower() != "reason":
reasons.append(row[0].strip())
except Exception:
pass
return reasons
def search_staff_suggestions(query: str, limit: int = 15) -> List[Dict[str, str]]:
"""Живой поиск сотрудников по zup_staff для автокомплита."""
q = (query or "").strip()
if not q or len(q) < 2:
return []
# Приводим к разным регистрам для гарантированного поиска кириллицы в SQLite
q_lower = q.lower()
q_title = q.capitalize()
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
# 1. Находим действительно самый свежий срез штата (по created_at или по структуре даты ГГГГ-ММ-ДД)
cursor.execute("""
SELECT snapshot_date
FROM zup_staff
ORDER BY
SUBSTR(snapshot_date, 7, 4) DESC,
SUBSTR(snapshot_date, 4, 2) DESC,
SUBSTR(snapshot_date, 1, 2) DESC,
id DESC
LIMIT 1
""")
row = cursor.fetchone()
latest_date = row[0] if row else None
if not latest_date:
return []
# 2. Поиск с сортировкой: сначала те, у кого фамилия НАЧИНАЕТСЯ с запроса
sql = """
SELECT DISTINCT fio, fio_clean, department, position
FROM zup_staff
WHERE snapshot_date = ?
AND (
fio LIKE ? OR fio LIKE ? OR fio_clean LIKE ? OR fio_clean LIKE ?
OR fio LIKE ? OR fio_clean LIKE ?
)
ORDER BY
CASE
WHEN fio LIKE ? OR fio_clean LIKE ? THEN 0
ELSE 1
END,
fio ASC
LIMIT ?
"""
prefix_pattern_title = f"{q_title}%"
prefix_pattern_lower = f"{q_lower}%"
any_pattern_title = f"%{q_title}%"
any_pattern_lower = f"%{q_lower}%"
cursor.execute(sql, (
latest_date,
prefix_pattern_title, prefix_pattern_lower, prefix_pattern_title, prefix_pattern_lower,
any_pattern_title, any_pattern_lower,
prefix_pattern_title, prefix_pattern_title,
limit
))
rows = cursor.fetchall()
return [
{
"fio": r["fio"],
"fio_clean": r["fio_clean"],
"department": r["department"] or "—",
"position": r["position"] or "—"
}
for r in rows
]
def add_manual_absence(
absence_type: str,
fio: str,
reason: str,
department: str = "",
position: str = "",
date_start: Optional[str] = None,
date_end: Optional[str] = None,
comment: str = ""
) -> int:
init_manual_absences_table()
clean_fio = normalize_fio(fio)
if not clean_fio:
return 0
today_str = datetime.now().strftime("%Y-%m-%d")
d_start = date_start.strip() if date_start and date_start.strip() else today_str
d_end = date_end.strip() if date_end and date_end.strip() else today_str
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO manual_absences (
absence_type, fio, fio_clean, department, position,
date_start, date_end, reason, comment
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (absence_type.upper(), fio.strip(), clean_fio, department.strip(), position.strip(), d_start, d_end, reason.strip(), comment.strip()))
conn.commit()
return cursor.lastrowid
def delete_manual_absence(item_id: int) -> bool:
init_manual_absences_table()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM manual_absences WHERE id = ?", (item_id,))
conn.commit()
return cursor.rowcount > 0
def get_manual_absences_list(absence_type: Optional[str] = None) -> List[Dict[str, Any]]:
today_str = datetime.now().strftime("%Y-%m-%d")
# ⭐️ Обязательно row_factory=True для преобразования строк SQLite в dict
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
# 1. Автоочистка просроченных командировок и отсутствий
cursor.execute("""
DELETE FROM manual_absences
WHERE date_end IS NOT NULL
AND date_end != ''
AND date_end < ?
""", (today_str,))
conn.commit()
# 2. Выборка активных записей
query = "SELECT id, absence_type, fio, department, position, reason, date_start, date_end, comment FROM manual_absences"
params = []
if absence_type:
query += " WHERE absence_type = ?"
params.append(absence_type)
query += " ORDER BY id DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
return [dict(r) for r in rows]
def get_active_manual_absences_for_date(date_str: str) -> List[Dict[str, Any]]:
"""Выбирает записи, активные на дату отчета (формат даты ДД.ММ.ГГГГ)."""
init_manual_absences_table()
try:
dt_target = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
dt_target = datetime.now().strftime("%Y-%m-%d")
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason
FROM manual_absences
WHERE (date_start IS NULL OR date_start <= ?)
AND (date_end IS NULL OR date_end >= ?)
""", (dt_target, dt_target))
return [dict(r) for r in cursor.fetchall()]
+89
View File
@@ -0,0 +1,89 @@
"""
===============================================================================
FILE: services/office/document_extractor.py
PROJECT: SCUD Orion AI (Office Domain)
ROLE: Постраничное извлечение текста и OCR сканов документов через Vision LLM.
===============================================================================
"""
import os
import base64
import logging
from typing import List, Dict, Any, Optional
import fitz # PyMuPDF
from modules.web_api.llm.core.ollama_client import call_ollama_chat
logger = logging.getLogger("OFFICE_EXTRACTOR")
def ocr_image_b64(image_b64: str, page_num: int = 1) -> str:
"""
Распознает текст с одного растрового изображения через Qwen 2.5 VL.
"""
system_prompt = (
"Ты — высокоточный профессиональный модуль OCR для канцелярии и документооборота.\n"
"Твоя задача — точно переписать весь текст с предоставленного изображения документа.\n"
"ПРАВИЛА:\n"
"1. Переписывай текст дословно, сохраняя структуру, заголовки, списки, нумерацию и таблицы.\n"
"2. Запрещено добавлять вводные слова, приветствия, комментарии ('Спасибо за обращение', 'Вот текст' и т.д.).\n"
"3. Выводи СТРОГО чистый распознанный текст документа."
)
user_message = {
"role": "user",
"content": f"Распознай весь печатный и рукописный текст страницы №{page_num} без пропусков.",
"images": [image_b64]
}
try:
res = call_ollama_chat(
messages=[{"role": "system", "content": system_prompt}, user_message],
is_vision=True,
timeout=300 # 5 минут на тяжелые страницы
)
return (res.get("content") or "").strip()
except Exception as e:
logger.error(f"[Office OCR] Ошибка распознавания страницы {page_num}: {e}")
return f"[Ошибка распознавания страницы {page_num}: {e}]"
def process_pdf_full(file_path: str, max_pages: Optional[int] = None) -> List[Dict[str, Any]]:
"""
Обрабатывает PDF целиком:
- Если есть качественный цифровой текст — мгновенно извлекает его со всех страниц.
- Если страница является сканом/картинкой — рендерит ее в 200 DPI и прогоняет через Vision OCR.
"""
results = []
doc = fitz.open(file_path)
total_pages = len(doc)
limit = min(total_pages, max_pages) if max_pages else total_pages
logger.info(f"[Office] Начало обработки PDF: {os.path.basename(file_path)} (всего страниц: {total_pages})")
for idx in range(limit):
page_num = idx + 1
page = doc[idx]
extracted_text = (page.get_text("text") or "").strip()
# Если на странице есть хороший машинный текст (не скан)
if len(extracted_text) > 80:
logger.info(f"[Office] Страница {page_num}/{limit}: извлечен цифровой текст ({len(extracted_text)} симв.)")
results.append({
"page": page_num,
"method": "DIGITAL_TEXT",
"text": extracted_text
})
else:
# Чистый скан — рендерим страницу в PNG и передаем в Vision LLM
logger.info(f"[Office] Страница {page_num}/{limit}: распознавание скана через Ollama Vision...")
pix = page.get_pixmap(dpi=200)
img_b64 = base64.b64encode(pix.tobytes("png")).decode("utf-8")
ocr_text = ocr_image_b64(img_b64, page_num=page_num)
results.append({
"page": page_num,
"method": "VISION_OCR",
"text": ocr_text
})
doc.close()
return results
+55
View File
@@ -0,0 +1,55 @@
"""
===============================================================================
FILE: services/office/service.py
PROJECT: SCUD Orion AI (Office Domain)
ROLE: Единый фасад офисного модуля для вызова из API и фоновых задач.
===============================================================================
"""
import os
import uuid
import logging
from typing import Dict, Any
from config import BASE_DIR
from .document_extractor import process_pdf_full
from .word_builder import build_docx_from_ocr
logger = logging.getLogger("OFFICE_SERVICE")
OFFICE_OUTPUT_DIR = os.path.join(BASE_DIR, "output", "web", "office")
os.makedirs(OFFICE_OUTPUT_DIR, exist_ok=True)
def convert_pdf_to_word_service(file_path: str, original_filename: str) -> Dict[str, Any]:
"""
Полный цикл: Постраничный OCR скана PDF -> Сборка документа DOCX -> Ссылка на скачивание.
"""
session_id = str(uuid.uuid4())[:8]
base_name = os.path.splitext(os.path.basename(original_filename))[0]
out_docx_name = f"{base_name}_распознан.docx"
target_dir = os.path.join(OFFICE_OUTPUT_DIR, session_id)
os.makedirs(target_dir, exist_ok=True)
out_docx_path = os.path.join(target_dir, out_docx_name)
# 1. Постраничный парсинг всех страниц
pages = process_pdf_full(file_path)
# 2. Сборка Word-документа
build_docx_from_ocr(pages, out_docx_path, doc_title=base_name)
# 3. Краткое превью для окна чата
preview_sample = pages[0]["text"][:600] if pages else "Документ распознан."
download_url = f"/api/v1/files/download/office/{session_id}/{out_docx_name}"
return {
"status": "success",
"total_pages": len(pages),
"filename": out_docx_name,
"filepath": out_docx_path,
"download_url": download_url,
"preview_text": preview_sample,
"message": f"Документ успешно распознан целиком ({len(pages)} стр.) и собран в MS Word."
}
+63
View File
@@ -0,0 +1,63 @@
"""
===============================================================================
FILE: services/office/word_builder.py
PROJECT: SCUD Orion AI (Office Domain)
ROLE: Сборка форматированного документа MS Word (.docx) из распознанного текста.
===============================================================================
"""
import os
from typing import List, Dict, Any
from docx import Document
from docx.shared import Pt, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
def build_docx_from_ocr(pages_data: List[Dict[str, Any]], output_filepath: str, doc_title: str = "Распознанный документ") -> str:
"""
Создает файл .docx по ГОСТ-стандартам делопроизводства:
- Шрифт Times New Roman 12-14pt.
- Межстрочный интервал 1.15.
- Разделители страниц и колонтитулы.
"""
doc = Document()
sections = doc.sections
for section in sections:
section.top_margin = Inches(0.79)
section.bottom_margin = Inches(0.79)
section.left_margin = Inches(0.79)
section.right_margin = Inches(0.59)
for p_idx, page in enumerate(pages_data):
page_num = page.get("page", p_idx + 1)
text_content = page.get("text", "")
if p_idx > 0:
doc.add_page_break()
lines = text_content.splitlines()
for line in lines:
line_str = line.strip()
if not line_str:
continue
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(3)
p.paragraph_format.line_spacing = 1.15
if any(h in line_str.upper() for h in ["ПРЕДСЕДАТЕЛЬСТВОВАЛ", "ПРОТОКОЛ", "ПОВЕСТКА", "РЕШИЛИ:", "ОТМЕТИЛИ:"]):
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(line_str)
run.font.name = "Times New Roman"
run.font.size = Pt(13)
run.font.bold = True
else:
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
run = p.add_run(line_str)
run.font.name = "Times New Roman"
run.font.size = Pt(12)
os.makedirs(os.path.dirname(output_filepath), exist_ok=True)
doc.save(output_filepath)
return output_filepath
+256
View File
@@ -0,0 +1,256 @@
"""
===============================================================================
FILE: services/presence_service.py
ROLE: Сервис оперативного мониторинга («Кто в здании прямо сейчас»).
1. Строгое соответствие общему штату 1С (286 чел.).
2. Служебный персонал (уборщики, контролеры) маркируется как EXCLUDED
и не искажает вкладки "Не пришли" и "В здании".
3. Сотрудники флигеля/двора (Чупряев, Пухаренко, БЛ) при выходе через
турникет во двор остаются со статусом "В здании (Флигель/Двор)".
===============================================================================
"""
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
import pandas as pd
from core.connection import get_connection
from config import DATE_TODAY, normalize_fio
from services.exceptions_repo import get_all_exceptions_from_db
from services.data_loader import load_1c_data_smart
from services.scud_export import run_export
logger = logging.getLogger("PRESENCE_SERVICE")
def get_latest_zup_staff() -> Optional[pd.DataFrame]:
"""Загружает самый свежий доступный срез штата из zup_staff."""
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_date
FROM zup_staff
ORDER BY
SUBSTR(snapshot_date, 7, 4) DESC,
SUBSTR(snapshot_date, 4, 2) DESC,
SUBSTR(snapshot_date, 1, 2) DESC,
id DESC
LIMIT 1
""")
row = cursor.fetchone()
if not row:
return None
latest_date = row[0]
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(latest_date,)
)
return df if not df.empty else None
def get_live_presence(date_str: Optional[str] = None, force_refresh: bool = False) -> Dict[str, Any]:
clean_date = (date_str or DATE_TODAY).replace('_', '.')
data_source = "LOCAL_SQLITE"
# 1. Принудительный опрос MS SQL при запросе
if force_refresh:
logger.info(f"[Presence] Прямой опрос MS SQL Орион за {clean_date}...")
try:
run_export(input_date=clean_date, save_xlsx=False, debug=False)
data_source = "LIVE_MSSQL"
except Exception as e:
logger.error(f"[Presence] Ошибка при обращении к MS SQL: {e}")
# 2. Выборка последних событий по каждому сотруднику за сегодня
events_raw = []
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
query = """
WITH RankedEvents AS (
SELECT
hoz_organ,
fio,
fio_clean,
department,
time_val,
direction,
ROW_NUMBER() OVER (
PARTITION BY fio_clean
ORDER BY time_val DESC, id DESC
) as rn
FROM scud_events_raw
WHERE log_date = ?
)
SELECT hoz_organ, fio, fio_clean, department, time_val, direction
FROM RankedEvents
WHERE rn = 1;
"""
cursor.execute(query, (clean_date,))
events_raw = [dict(r) for r in cursor.fetchall()]
last_events_map = {r['fio_clean']: r for r in events_raw}
# 3. Штат 1С и кадровые отклонения
df_staff, df_abs = load_1c_data_smart(clean_date, use_db=True)
if df_staff is None or df_staff.empty:
df_staff = get_latest_zup_staff()
absences_map = {}
if df_abs is not None and not df_abs.empty:
for _, row in df_abs.iterrows():
fc = row.get('fio_clean')
reason = str(row.get('Вид_отсутствия', '')).strip()
if fc and reason:
absences_map[fc] = reason
# 4. Исключения и реестр флигеля из базы данных
try:
exceptions_cfg = get_all_exceptions_from_db()
except Exception:
exceptions_cfg = {}
exc_fios = set([normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f])
exc_depts = set([str(d).strip().lower() for d in exceptions_cfg.get("departments", []) if d])
exc_positions = set([str(p).strip().lower() for p in exceptions_cfg.get("positions", []) if p])
# Реестр флигеля / двора (ФИО и отделы)
fligel_fios = set([normalize_fio(f) for f in exceptions_cfg.get("fligel_fio", []) if f])
fligel_depts = set([str(d).strip().lower() for d in exceptions_cfg.get("fligel_departments", []) if d])
# Авто-добавление ключевых сотрудников и подразделений лаборатории БЛ
fligel_depts.update(["бл", "бетонная лаборатория", "испытательная геотехническая лаборатория"])
fligel_fios.update([normalize_fio("Чупряев Антон Михайлович"), normalize_fio("Пухаренко Ольга Юрьевна")])
# Ключевые слова технического персонала (клининг, контролеры КПП)
default_exc_keywords = ["уборщ", "дворник", "контролер", "внутреннего контроля", "клининг"]
def check_is_excluded(fio_c: str, dept: str, pos: str) -> bool:
if fio_c in exc_fios:
return True
d_lower = str(dept).strip().lower()
if d_lower in exc_depts or any(k in d_lower for k in ["контрол", "клининг"]):
return True
p_lower = str(pos).strip().lower()
if p_lower in exc_positions or any(k in p_lower for k in default_exc_keywords):
return True
return False
def check_is_fligel(fio_c: str, dept: str) -> bool:
if fio_c in fligel_fios:
return True
d_lower = str(dept).strip().lower()
return d_lower in fligel_depts or any(k in d_lower for k in ["бетонная лаб", "геотехническая лаб"])
# 5. Формирование полного списка сотрудников
staff_items = []
seen_fios = set()
# 5.1. Обработка всех сотрудников из официального штата 1С
if df_staff is not None and not df_staff.empty:
for _, s_row in df_staff.iterrows():
fio_raw = s_row.get('ФИО', '')
fio_c = s_row.get('fio_clean', normalize_fio(fio_raw))
if not fio_c or fio_c in seen_fios:
continue
seen_fios.add(fio_c)
dept_1c = str(s_row.get('Подразделение', '—')).strip()
pos = str(s_row.get('Должность', '—')).strip()
is_excluded = check_is_excluded(fio_c, dept_1c, pos)
is_fligel = check_is_fligel(fio_c, dept_1c)
absence_reason = absences_map.get(fio_c, "")
event = last_events_map.get(fio_c)
has_events_today = event is not None
last_time = event['time_val'].split()[-1][:5] if event and ' ' in event['time_val'] else (event['time_val'][:5] if event else '—')
direction = str(event.get('direction', '')).upper() if event else ""
dept_val = str(event['department']).strip() if (event and event.get('department') and str(event['department']).strip() not in ['—', 'Без подразделения', '']) else dept_1c
is_remote = "удален" in absence_reason.lower() or "дистанцион" in absence_reason.lower()
is_trip_or_leave = bool(absence_reason) and not is_remote
# ⭐️ ЛОГИКА ОПРЕДЕЛЕНИЯ СТАТУСА:
if is_excluded:
# Сотрудник входит в штат 1С, но имеет служебный статус исключения
status = "EXCLUDED"
status_label = "Исключение (Служебный)"
elif is_fligel and has_events_today:
# ⭐️ Сотрудник флигеля/двора: выход через турникет = внутридневной выход во двор на рабочее место
status = "INSIDE"
status_label = "В здании (Флигель/Двор)"
elif direction == "OUT":
status = "OUTSIDE"
status_label = "Вышел"
elif direction == "IN" or has_events_today:
status = "INSIDE"
status_label = "В здании"
elif is_remote:
status = "REMOTE"
status_label = "Удаленная работа"
elif is_trip_or_leave:
status = "OFFICIAL_ABSENCE"
status_label = absence_reason
else:
status = "NOT_ENTERED"
status_label = "Не пришел"
staff_items.append({
"fio": fio_raw,
"fio_clean": fio_c,
"department": dept_val,
"position": pos,
"status": status,
"status_label": status_label,
"last_time": last_time,
"last_direction": direction or "NONE",
"absence_reason": absence_reason,
"is_excluded": is_excluded,
"is_fligel": is_fligel
})
# Сортировка: В здании -> Вышли -> Удаленка -> Отсутствуют -> Не пришли -> Исключения
status_order = {
"INSIDE": 0,
"OUTSIDE": 1,
"REMOTE": 2,
"OFFICIAL_ABSENCE": 3,
"NOT_ENTERED": 4,
"EXCLUDED": 5
}
staff_items.sort(key=lambda x: (status_order.get(x["status"], 6), x["fio"].lower()))
# 6. Метрики
total_staff = len(staff_items)
inside_count = sum(1 for x in staff_items if x["status"] == "INSIDE")
outside_count = sum(1 for x in staff_items if x["status"] == "OUTSIDE")
remote_count = sum(1 for x in staff_items if x["status"] == "REMOTE")
absence_count = sum(1 for x in staff_items if x["status"] == "OFFICIAL_ABSENCE")
not_entered_count = sum(1 for x in staff_items if x["status"] == "NOT_ENTERED")
excluded_count = sum(1 for x in staff_items if x["status"] == "EXCLUDED")
latest_event_time = "—"
if events_raw:
times = [r['time_val'] for r in events_raw if r.get('time_val')]
if times:
max_t = max(times)
latest_event_time = max_t.split()[-1][:5] if ' ' in max_t else max_t[:5]
return {
"date": clean_date,
"data_source": data_source,
"latest_event_time": latest_event_time,
"timestamp": datetime.now().strftime("%H:%M:%S"),
"metrics": {
"total_staff": total_staff,
"inside": inside_count,
"outside": outside_count,
"remote": remote_count,
"official_absence": absence_count,
"not_entered": not_entered_count,
"excluded": excluded_count
},
"records": staff_items
}
View File
+79
View File
@@ -0,0 +1,79 @@
"""
===============================================================================
FILE: services/prompts/diff_engine.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / prompts
ROLE: Формирование превью изменений промпта и генерация визуального HTML-Diff.
AI-CONTEXT-ANCHORS:
- ANCHOR[PROMPT_DIFF_BUILDER]: Генерация черновика и HTML-разметки изменений.
===============================================================================
"""
from typing import Tuple, Dict, List, Set
from core.connection import get_connection
def build_prompt_diff(
action: str,
section_id: int = None,
item_id: int = None,
content: str = "",
delete_nodes: List[Tuple[int, int]] = None,
prompt_name: str = "main_agent"
) -> Tuple[str, str]:
act = (action or "ADD").upper()
nodes_to_delete: Set[Tuple[int, int]] = set()
if delete_nodes:
nodes_to_delete.update(delete_nodes)
elif act == "DELETE" and section_id is not None and item_id is not None:
nodes_to_delete.add((section_id, item_id))
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = ? AND is_active = 1
ORDER BY section_id, item_id
""", (prompt_name,))
existing_nodes = cursor.fetchall()
nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes}
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k not in nodes_to_delete}
if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None:
nodes_dict_for_draft[(section_id, item_id)] = content
draft_lines = []
curr_sec = None
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
if i_id == 0:
if curr_sec is not None: draft_lines.append("")
draft_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
draft_lines.append(f" {s_id}.{i_id}. {txt}")
merged_prompt = "\n".join(draft_lines)
diff_lines = []
curr_sec = None
display_nodes = dict(nodes_dict)
if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None:
display_nodes[(section_id, item_id)] = content
for (s_id, i_id), txt in sorted(display_nodes.items()):
if i_id == 0:
if curr_sec is not None: diff_lines.append("")
diff_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
if (s_id, i_id) in nodes_to_delete:
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
elif act == "ADD" and s_id == section_id and i_id == item_id:
line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
else:
line_str = f" {s_id}.{i_id}. {txt}"
diff_lines.append(line_str)
return merged_prompt, "\n".join(diff_lines)
+114
View File
@@ -0,0 +1,114 @@
"""
===============================================================================
FILE: services/prompts/repository.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / prompts
ROLE: Реляционное хранилище узлов системного промпта (таблица system_prompt_nodes).
AI-CONTEXT-ANCHORS:
- ANCHOR[PROMPT_REPO_GET_ACTIVE]: Сборка активного промпта из узлов БД.
- ANCHOR[PROMPT_REPO_APPLY_ACTION]: Точечная вставка / изменение / удаление узла.
===============================================================================
"""
import re
import logging
from typing import List, Tuple, Dict, Any
from core.connection import get_connection
logger = logging.getLogger("PROMPT_REPO")
# ANCHOR[PROMPT_REPO_GET_ACTIVE]
def repo_get_active_prompt(prompt_name: str = "main_agent") -> str:
"""Собирает структурированный текст системного промпта из активных узлов."""
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = ? AND is_active = 1
ORDER BY section_id ASC, item_id ASC
""", (prompt_name,))
rows = cursor.fetchall()
if not rows:
return "Ты — ИИ-ассистент SCUD Orion AI."
lines = []
current_section = None
for r in rows:
sec_id = r["section_id"]
itm_id = r["item_id"]
content = r["content"]
if itm_id == 0:
if current_section is not None:
lines.append("")
lines.append(f"{sec_id}. {content}")
current_section = sec_id
else:
lines.append(f" {sec_id}.{itm_id}. {content}")
return "\n".join(lines)
# ANCHOR[PROMPT_REPO_APPLY_ACTION]
def repo_apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> None:
"""Точечно применяет действие (ADD / UPDATE / DELETE) над узлом промпта."""
with get_connection() as conn:
cursor = conn.cursor()
action_clean = (action or "").upper()
if action_clean in ["ADD", "UPDATE", "EDIT"]:
cursor.execute("""
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
content = excluded.content,
is_active = 1,
updated_at = CURRENT_TIMESTAMP
""", (prompt_name, section_id, item_id, content))
elif action_clean == "DELETE":
cursor.execute("""
DELETE FROM system_prompt_nodes
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
""", (prompt_name, section_id, item_id))
conn.commit()
def repo_save_full_prompt(prompt_text: str, prompt_name: str = "main_agent") -> None:
"""Парсит и полностью перезаписывает все узлы промпта из сырого текста."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (prompt_name,))
current_sec = 1
current_itm = 0
for raw_line in prompt_text.splitlines():
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
if not clean_line:
continue
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
if sub_match:
current_sec = int(sub_match.group(1))
current_itm = int(sub_match.group(2))
content = sub_match.group(3).strip()
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
current_sec = int(sec_match.group(1))
current_itm = 0
content = sec_match.group(2).strip()
else:
current_itm += 1
content = clean_line
cursor.execute("""
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
VALUES (?, ?, ?, ?, 1)
""", (prompt_name, current_sec, current_itm, content))
conn.commit()
+49
View File
@@ -0,0 +1,49 @@
"""
===============================================================================
FILE: services/prompts/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / prompts
ROLE: Единый доменный сервис управления системным промптом.
===============================================================================
"""
from typing import Tuple, Dict, Any, List, Optional
from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt
from .diff_engine import build_prompt_diff
def get_active_system_prompt() -> str:
"""Получить текущий активный системный промпт."""
return repo_get_active_prompt()
def apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "") -> None:
"""Применить точечное изменение к узлу промпта."""
repo_apply_prompt_action(action, section_id, item_id, content)
def save_full_prompt_draft(draft_text: str, prompt_name: str = "main_agent") -> None:
"""Сохранить полный черновик промпта."""
repo_save_full_prompt(draft_text, prompt_name=prompt_name)
def create_prompt_preview(
action: str,
section_id: Optional[int] = None,
item_id: Optional[int] = None,
content: str = "",
delete_nodes: Optional[List[Tuple[int, int]]] = None
) -> Tuple[str, str, str]:
"""
Формирует черновик и diff.
Возвращает (merged_draft, diff_html, baseline_prompt).
"""
baseline = repo_get_active_prompt()
draft, diff_html = build_prompt_diff(
action=action,
section_id=section_id,
item_id=item_id,
content=content,
delete_nodes=delete_nodes
)
return draft, diff_html, baseline
View File
+56
View File
@@ -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"
+119
View File
@@ -0,0 +1,119 @@
"""
===============================================================================
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"
is_exc = merged_df.get('is_excluded', False) == True
is_not_hired = merged_df.get('not_hired_yet', False) == True
# Исключаем из отчета за вчера сотрудников-исключений И тех, кто еще не принят на работу
df_export = merged_df[(~is_exc) & (~is_not_hired)].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)
# Включаем опцию защиты nan_inf_to_errors
wb = xlsxwriter.Workbook(output_path, {'nan_inf_to_errors': True})
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 = bool(row.get('Пришел', False))
absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() not in ['', 'nan', 'None']
in_val = str(row.get(start_col, 'Нет входа')).strip()
if in_val.lower() in ['nan', 'none']: in_val = 'Нет входа'
out_val = str(row.get(end_col, 'Нет выхода')).strip()
if out_val.lower() in ['nan', 'none']: out_val = 'Нет выхода'
in_building_str = str(row.get(hours_col, '00:00')).strip()
if in_building_str.lower() in ['nan', 'none']: in_building_str = '00:00'
first_act_val = str(row.get('Первая_активность', '—')).strip()
if first_act_val.lower() in ['nan', 'none']: first_act_val = '—'
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_raw = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
dept_scud_val = str(dept_raw).strip() if pd.notna(dept_raw) else '—'
if dept_scud_val.lower() in ['nan', 'none']: dept_scud_val = '—'
fio_raw = row.get('Сотрудник', '')
fio_val = str(fio_raw).strip() if pd.notna(fio_raw) else ''
if fio_val.lower() in ['nan', 'none']: fio_val = ''
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).strip() if has_reason else ""
if val_h_str.lower() in ['nan', 'none']: val_h_str = ""
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),
(fio_val, '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),
(val_h_str, 'left', True),
(8, 'center', False),
(deviation_val, 'center', False)
]
for col_idx, (val, align_type, is_wrap) in enumerate(values):
# Санитизация для исключения передачи float('nan') в _write_number
if pd.isna(val) or val is None or str(val).strip().lower() == 'nan':
val = ""
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)
+42
View File
@@ -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)
+147
View File
@@ -0,0 +1,147 @@
"""
===============================================================================
FILE: services/reports/simplified_builder.py
ROLE: Генератор книги "Упрощенный отчет за ДД.ММ.ГГГГг..xlsx"
на основе агрегированных данных СКУД и 1С.
===============================================================================
"""
import os
import openpyxl
from openpyxl.styles import Font, Alignment, Border, Side
from datetime import datetime, time
import pandas as pd
from config import REPORTS_DIR
from services.reports.styles import get_dated_reports_dir
def format_fio_initials(full_fio: str) -> str:
"""Преобразует 'Иванов Иван Иванович' в 'Иванов И.И.'"""
if not full_fio or pd.isna(full_fio):
return ""
parts = str(full_fio).strip().split()
if len(parts) >= 3:
return f"{parts[0]} {parts[1][0]}.{parts[2][0]}."
elif len(parts) == 2:
return f"{parts[0]} {parts[1][0]}."
return str(full_fio).strip()
def parse_time_cell(val, default_empty="Нет входа (0:00)"):
"""Преобразует строку времени СКУД в time(hh, mm) либо оставляет текстовую заглушку."""
if not val or pd.isna(val):
return default_empty
s = str(val).strip()
if s in ["Нет входа", "—", "", "nan", "None", "00:00:00", "00:00"]:
return default_empty
if s in ["Нет выхода"]:
return "Нет выхода (23:59)"
# Если уже пришёл time
if isinstance(val, time):
return val
try:
parts = s.split(":")
h = int(parts[0])
m = int(parts[1]) if len(parts) > 1 else 0
return time(h, m)
except Exception:
return s
def generate_simplified_excel(df_merged: pd.DataFrame, date_str: str, filename: str = None) -> str:
"""
Генерирует Excel-файл упрощенного отчета точно по образцу Ориона.
Структура:
A: № | B: Подразделение | C: Сотрудник | D: Должность | E: Начало дня | F: Конец дня
"""
clean_date = str(date_str).replace('_', '.')
if not filename:
filename = f"Упрощенный отчет за {clean_date}г..xlsx"
target_dir = get_dated_reports_dir(clean_date)
output_path = os.path.join(target_dir, filename)
# Исключаем тех, кто не принят на работу и исключения
is_exc = df_merged.get('is_excluded', False) == True
is_not_hired = df_merged.get('not_hired_yet', False) == True
df_src = df_merged[(~is_exc) & (~is_not_hired)].copy()
# Подготовка данных
rows_to_sort = []
for _, r in df_src.iterrows():
fio_raw = r.get('Сотрудник', r.get('ФИО', ''))
dept = str(r.get('Подразделение', '—')).strip()
pos = str(r.get('Должность', '—')).strip()
if pos.lower() in ['nan', 'none', '']: pos = '—'
if dept.lower() in ['nan', 'none', '']: dept = '—'
fio_short = format_fio_initials(fio_raw)
t_in = parse_time_cell(r.get('Начало_дня'), default_empty="Нет входа (0:00)")
t_out = parse_time_cell(r.get('Конец_дня'), default_empty="Нет выхода (23:59)")
rows_to_sort.append({
'dept': dept,
'fio_short': fio_short,
'pos': pos,
't_in': t_in,
't_out': t_out
})
# Сортировка: Подразделение (А-Я), затем Сотрудник (А-Я)
rows_sorted = sorted(rows_to_sort, key=lambda x: (x['dept'].lower(), x['fio_short'].lower()))
# Создание книги openpyxl для точного соблюдения структуры образца
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Лист1"
# Стили по образцу
font_main = Font(name="Calibri", size=11, bold=False)
align_center = Alignment(horizontal="center", vertical="center")
border_thin = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin")
)
# Заголовок
ws["B1"] = f"Упрощенный отчет: c {clean_date} по {clean_date}"
ws["B1"].font = font_main
# Шапка таблицы (Строка 3)
headers = [None, "Подразделение", "Сотрудник", "Должность", "Начало дня", "Конец дня"]
for col_idx, h_text in enumerate(headers, start=1):
cell = ws.cell(row=3, column=col_idx, value=h_text)
cell.font = font_main
cell.alignment = align_center
cell.border = border_thin
# Заполнение строк данных (с 4 строки)
for idx, item in enumerate(rows_sorted, start=1):
row_num = 3 + idx
vals = [idx, item['dept'], item['fio_short'], item['pos'], item['t_in'], item['t_out']]
for col_idx, val in enumerate(vals, start=1):
cell = ws.cell(row=row_num, column=col_idx, value=val)
cell.font = font_main
cell.alignment = align_center
cell.border = border_thin
# Числовой формат времени h:mm
if isinstance(val, time):
cell.number_format = "h:mm"
# Настройка ширины колонок по образцу
widths = {'A': 13.0, 'B': 25.0, 'C': 27.0, 'D': 80.0, 'E': 21.5, 'F': 23.3}
for col_letter, w in widths.items():
ws.column_dimensions[col_letter].width = w
# Создание пустых Лист2 и Лист3 как в оригинальном шаблоне
wb.create_sheet("Лист2")
wb.create_sheet("Лист3")
wb.save(output_path)
return output_path

Some files were not shown because too many files have changed in this diff Show More