Author SHA1 Message Date
manoraga 304208760b chore: save working baseline before v3.0 architecture refactoring 2026-08-21 09:32:07 +03:00
110 changed files with 12539 additions and 25138 deletions
-7
View File
@@ -27,10 +27,3 @@ 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
+18 -28
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, REPORTS_DIR]:
for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]:
os.makedirs(folder, exist_ok=True)
NOW = datetime.now()
@@ -23,30 +23,32 @@ if NOW.weekday() == 0:
else:
DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
OLLAMA_URL = "http://10.121.17.227:11434/api/generate"
# --- Настройки LLM / Ollama ---
OLLAMA_URL = "http://192.168.11.3: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}",
"driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}"
"server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"),
"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"
"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-шифрование
}
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('.', '_')
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('.', '_')
for d in search_dirs:
if not os.path.exists(d):
@@ -73,18 +75,6 @@ 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:
@@ -92,4 +82,4 @@ def load_exceptions():
return json.load(f)
except Exception:
pass
return {"fio": [], "departments": [], "positions": [], "position_keywords": [], "include_fio": []}
return {"fio": [], "departments": [], "positions": [], "position_keywords": []}
-27
View File
@@ -1,27 +0,0 @@
"""
===============================================================================
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
+472 -49
View File
@@ -1,57 +1,480 @@
"""
===============================================================================
FILE: core/database.py
ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов.
===============================================================================
"""
import os
import sqlite3
import json
import pandas as pd
from config import OUTPUT_DIR
from datetime import datetime
from config import DATA_DIR
from core.connection import get_connection, DB_PATH
from core.schema import init_all_tables
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
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
)
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
)
def get_connection():
"""Создает подключение к базе данных SQLite с оптимизированными настройками."""
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;")
return conn
init_db = init_all_tables
def dump_database_to_excel(out_filename: str = "db_dump_full.xlsx") -> str:
"""Создает полный дамп всех ключевых таблиц SQLite в многостраничный Excel."""
out_path = os.path.join(OUTPUT_DIR, out_filename)
tables = [
'scud_logs', 'scud_events_raw', 'zup_staff', 'zup_absences',
'anomalies_history', 'ai_knowledge_base', 'chat_messages',
'session_states', 'system_prompt_nodes', 'tasks',
'exceptions_registry', 'manual_absences', 'person_identity_mapping'
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).
"""
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
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, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in tables:
try:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
except Exception:
pass
return out_path
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
-359
View File
@@ -1,359 +0,0 @@
"""
===============================================================================
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
@@ -1,97 +0,0 @@
"""
===============================================================================
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
@@ -1,196 +0,0 @@
"""
===============================================================================
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.
-6
View File
@@ -1,6 +0,0 @@
reason
отгул
дежурство
обучение
экзамен в Ростехнадзоре
выходной день по ТД
1 reason
2 отгул
3 дежурство
4 обучение
5 экзамен в Ростехнадзоре
6 выходной день по ТД
+13 -13
View File
@@ -1,13 +1,13 @@
fio,department,reason,date_from,date_to
Королёва Наталья Александровна,Все,Удаленная работа,,
Николаева Ирина Леонидовна,Все,Удаленная работа,,
Познякова Татьяна Сергеевна,Все,Удаленная работа,,
Чуб Александр Васильевич,Все,Удаленная работа,,
Шуличенко Иван Иванович,Все,Удаленная работа,,
Пухаренко Юрий Владимирович,Все,Удаленная работа,,
Пшеничный Виктор Петрович,Все,Удаленная работа,,
Незнанова Валерия Игоревна,Все,Удаленная работа,,
Ковалев Владимир Владимирович,Все,Удаленная работа,,
Кожокарь Татьяна Юрьевна,Все,Удаленная работа,,
Субетто Юлия Викторовна,Все,Удаленная работа,,
Чуркина Елена Геннадьевна,Все,Удаленная работа,,
fio,reason,note
Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка
Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка
Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка
Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка
Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка
Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка
1 fio department reason note date_from date_to
2 Королёва Наталья Александровна Все Удаленная работа Постоянная удаленка
3 Николаева Ирина Леонидовна Все Удаленная работа Постоянная удаленка
4 Познякова Татьяна Сергеевна Все Удаленная работа Постоянная удаленка
5 Чуб Александр Васильевич Софьин Никита Сергеевич Все Удаленная работа Постоянная удаленка
6 Шуличенко Иван Иванович Чуб Александр Васильевич Все Удаленная работа Постоянная удаленка
7 Пухаренко Юрий Владимирович Шуличенко Иван Иванович Все Удаленная работа Постоянная удаленка
8 Пшеничный Виктор Петрович Пухаренко Юрий Владимирович Все Удаленная работа Постоянная удаленка
9 Незнанова Валерия Игоревна Пшеничный Виктор Петрович Все Удаленная работа Постоянная удаленка
10 Ковалев Владимир Владимирович Незнанова Валерия Игоревна Все Удаленная работа Постоянная удаленка
11 Кожокарь Татьяна Юрьевна Ковалев Владимир Владимирович Все Удаленная работа Постоянная удаленка
12 Субетто Юлия Викторовна Кожокарь Татьяна Юрьевна Все Удаленная работа Постоянная удаленка
13 Чуркина Елена Геннадьевна Субетто Юлия Викторовна Все Удаленная работа Постоянная удаленка
+24 -230
View File
@@ -1,226 +1,6 @@
# Changelog
# 📋 История изменений (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`.
Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6].
---
@@ -261,28 +41,42 @@
- Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`.
* **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий.
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**:
- Прямой просмотр промпта (`db_get_system_prompt`) переведен в прозрачный режим без блокировки строки ввода и без лишних кнопок.
- В инструмент `db_prompt_node_edit` добавлена передача исходного `baseline_prompt` для точного сопоставления правок в UI.
* **`modules/web_api/llm/core/fast_path.py`**: Оптимизирована зачистка эфемерных сообщений при кликах на кнопки фазы follow-up.
---
## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling
### ✨ Добавлено
* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`.
* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling.
* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`[cite: 6].
* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling[cite: 6].
### 🔧 Изменено
* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя[cite: 6].
* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога[cite: 6].
---
## [2026-08-15] — Реляционная архитектура системного промпта
### ✨ Добавлено
* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`).
* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта.
* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`.
* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`)[cite: 6].
* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта[cite: 6].
* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`[cite: 6].
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite[cite: 6].
* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных[cite: 6].
---
## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога
### ✨ Добавлено
* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков.
* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций.
* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия.
* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков[cite: 6].
* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций[cite: 6].
* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия[cite: 6].
+100 -212
View File
@@ -1,248 +1,136 @@
# План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов.
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`).
## 1. Очистка от регулярок и костылей (`tool_injector.py`)
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 4].
---
## 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].
## 2. Настройка контекста и инструкций сессии (`agent.py`)
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4].
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4].
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4].
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 4].
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 4].
---
## 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].
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`)
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4].
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4].
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4].
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 4].
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 4].
---
## 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].
## 4. Инлайн-редактор в окне диалога (`core.js`)
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4].
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4].
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 4].
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 4].
---
## 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].
## 5. Тестирование и валидация системного промпта
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4].
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4].
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4].
---
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]`
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`)
- [x] **Масштабирование UI задач:**
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 2].
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 2].
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 2].
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 4].
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 4].
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 4].
- [x] **Инлайн-редактирование карточки задачи:**
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 2].
- Отображение даты создания задачи[cite: 2].
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 2].
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 4].
- Отображение даты создания задачи[cite: 4].
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 4].
- [x] **Детерминированный Fast-Path и двухфазное удаление:**
- Мгновенная смена статусов без задержек LLM[cite: 2].
- Карточка подтверждения удаления с автоочисткой контекста[cite: 2].
- Мгновенная смена статусов без задержек LLM[cite: 4].
- Карточка подтверждения удаления с автоочисткой контекста[cite: 4].
- [x] **Генерация отчетов задач в Markdown:**
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 2].
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 2].
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3].
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3].
- [x] **Доменная консолидация задач:**
- Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 2].
- Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 3].
---
## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]`
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00 / 23:59:59[cite: 2].
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 2].
## 7. Распространение на модуль снапшотов (`snapshots`)
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 4].
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 4].
- [x] **Интерактивный UI с чекбоксами и защитой срезов:**
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 2].
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 2].
- Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 2].
- [x] **Декларативное управление удалением срезов:**
- Добавлен параметр `confirmed` в схему `TOOLS_SCHEMA` для `db_delete_snapshots`, исключающий зацикливание подтверждений в LLM.
- Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 2].
- Корректное отображение времени создания срезов в интерфейсе (устранение смещения UTC относительно локального времени сервера).
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3].
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3].
- Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 3].
- [x] **Детерминированный Fast-Path удаления срезов:**
- Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 3].
- Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления[cite: 3].
---
## 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].
## 8. Архитектурный рефакторинг и декомпозиция монолитов (Текущий этап)
> **Контроль версий:** перед каждым этапом и после его успешной проверки выполняется коммит и фиксация в репозитории **Gitea**.
- [ ] **Шаг 0: Фиксация базового состояния в Gitea:**
- Создание тега/коммита `v2.5-pre-refactor` в Gitea перед структурными изменениями.
- [ ] **Шаг 1: Разделение слоя данных и ядра (`core/`):**
- Создание `core/connection.py` (единый SQLite Connection Manager с WAL и таймаутами).
- Создание `core/schema.py` (DDL-инициализация всех таблиц и индексов из `database.py`).
- Фиксация контрольной точки в Gitea.
- [ ] **Шаг 2: Выделение доменных сервисов (`services/` / `domain/`):**
- **Задачи (`services/tasks/`):** декомпозиция на `repository.py`, `service.py`, `exporter.py` (правило: до 100–150 строк на файл).
- **Системный промпт (`services/prompts/`):** декомпозиция на `repository.py`, `service.py`, `diff_engine.py`.
- **Снапшоты (`services/snapshots/`):** декомпозиция на `repository.py`, `service.py`.
- **База знаний и правила (`services/knowledge/`):** вынос правил арбитража и словаря синонимов подразделений.
- Фиксация контрольной точки в Gitea.
- [ ] **Шаг 3: Декомпозиция ETL-конвейера (`main_etl.py` и `scud_export.py`):**
- Выделение `services/scud_etl/pipeline.py` (оркестрация без длинных простыней кода).
- Выделение `services/scud_etl/merger.py` (чистая склейка Dataframe 1С ⟷ СКУД).
- Выделение `services/scud_etl/anomaly_detector.py` (алгоритмическая детекция нарушений).
- Выделение `services/scud_etl/sql_queries.py` (вынос длинных T-SQL запросов).
- Фиксация контрольной точки в Gitea.
- [ ] **Шаг 4: Выделение чистого ИИ-оркестратора (`modules/ai_engine/`):**
- Создание компактного `agent.py` (~60–80 строк) без ветвлений `if/elif` на сотни строк.
- Внедрение изолированных обработчиков инструментов:
- `handlers/task_handler.py`
- `handlers/prompt_handler.py`
- `handlers/snapshot_handler.py`
- `handlers/anomaly_handler.py`
- Создание `context_builder.py` (календарь, роли, сборка системного контекста).
- Фиксация контрольной точки в Gitea.
- [ ] **Шаг 5: Облегчение `web_api` и адаптация роутеров:**
- Превращение `web_api` исключительно в тонкий транспортный шлюз (FastAPI + Статика).
- Очистка роутеров от прямого SQL — вызовы только через доменные сервисы.
- Рефакторинг CLI-утилиты `scripts/db_cli.py` (вынос форматирования в `scripts/cli/formatters.py`).
- Финальное сквозное тестирование и фиксация релиза в Gitea (`v3.0-clean-architecture`).
---
## 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].
## 9. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine)
- [ ] **Инструмент построения сводок (`db_generate_scud_report`):**
- Создание инструмента Function Calling для запуска конвейера контроллинга за выбранную дату/снапшот прямо из чата[cite: 4].
- Поддержка параметров: `target_date`, `snapshot_id`, `mode` (`summary` — дневная сводка, `detailed` — вчерашний отчет)[cite: 4].
- [ ] **Интерактивная карточка аудита отчета в UI:**
- Вывод статуса генерации, ключевых метрик (всего сотрудников, явка, опоздания, количество аномалий)[cite: 4].
- Ссылки на сгенерированные файлы Excel (`.xlsx`) и Markdown (`.md`)[cite: 4].
- [ ] **Диалоговый разбор аномалий и исключений:**
- Возможность в диалоге запросить расшифровку конкретной аномалии[cite: 4].
- Быстрое добавление сотрудника/отдела в `exceptions.json` или базу знаний прямо из чата по кнопке карточки[cite: 4].
- [ ] **Экспорт и отправка среза:**
- Команда формирования итоговой выгрузки для руководства в формате PDF/Markdown с передачей файла пользователю[cite: 4].
---
## 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. Исследовательский трек: Изолированная песочница кода `[ОТЛОЖЕНО]`
## 10. Изолированная песочница кода (Code Execution Sandbox Engine)
- [ ] **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:** перекрёстное сопоставление неразборчивых фамилий и инициалов с реестром участников совещания/штатом из тела самого документа.
- Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4].
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4].
- [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4].
-7053
View File
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -7,9 +7,10 @@
"Уборщик служебных помещений"
],
"fio": [
"Таткало Валерий Валерьевич",
"Петренюк Андрей Германович",
"Михалев Сергей Геннадьевич"
"Таткало Валерий Валерьевич",
"Петренюк Андрей Германович",
"Чуркина Елена Геннадьевна",
"Михалев Сергей Геннадьевич"
],
"position_keywords": [
"уборщик",
@@ -17,9 +18,5 @@
"дворник",
"гардероб",
"рабочий по обслуживанию"
],
"include_fio": [
"Тарасенко Александр Александрович",
"Журиков Михаил Николаевич"
]
}
+531 -177
View File
@@ -1,217 +1,571 @@
"""
===============================================================================
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
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
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
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.excel_exporter import export_raw_scud
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
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
)
def print_help():
print("""
===============================================================================
🛠️ SCUD ORION AI — СИСТЕМА КОНТРОЛЛИНГА И СВОДНЫХ ОТЧЕТОВ
===============================================================================
Использование:
python main_etl.py [ОПЦИИ]
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 {}
Доступные аргументы:
-h, --help, help Показать эту справку и выйти
--date ДД.ММ.ГГГГ Дата расчета (по умолчанию: текущий рабочий день)
--time ЧЧ:ММ Время среза для сводки (например: 14:30)
Ищет ближайший срез (±20 мин) или запрашивает On-Demand экспорт
--snapshot ID Точный ID снапшота для расчета (например: 20260827-002)
--skip-export Пропустить выгрузку СКУД из MS SQL (работать только с SQLite)
--export-only ТОЛЬКО сделать экспорт/снапшот СКУД в БД без построения отчетов
-d, --debug Включить режим расширенной отладки
Примеры использования:
python main_etl.py
👉 Полный суточный цикл: экспорт -> отчет за вчера -> сводка за сегодня -> ИИ.
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 --export-only
👉 Почасовой тихий срез в БД (для cron) без генерации отчетов.
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 --date 27.08.2026 --time 12:15 --skip-export
👉 Построить сводку за 27.08 на 12:15 без запроса к внешнему MS SQL.
df['is_excluded'] = False
python main_etl.py --snapshot 20260827-001 --skip-export
👉 Расчет отчетов строго по выбранному снапшоту из SQLite.
===============================================================================
""")
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)
def main():
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
print_help()
sys.exit(0)
help_text = """
Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2)
ПРИМЕРЫ ЗАПУСКА:
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()
if args.help:
print_help()
sys.exit(0)
DEBUG = args.debug
print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}")
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
print("=" * 60)
# Автоматическая ротация архивных почасовых срезов
if not args.skip_export and not args.snapshot:
deleted_count = cleanup_old_intermediate_snapshots(days_to_keep_all=2)
if deleted_count > 0:
print(f"[🧹] Ротация БД: очищено {deleted_count} строк промежуточных архивных срезов.")
init_db()
kb_data = load_knowledge_base()
kb_rules = kb_data.get("rules", [])
exceptions_cfg = load_exceptions_config()
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")
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:
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")
snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
# [Этап 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)
try:
run_export(save_xlsx=True, debug=DEBUG)
except Exception as e:
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
else:
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
# Если запрошен режим тихого почасового среза — выходим без тяжелых генераций
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("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...")
print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...")
has_today_1c = True
# [Этап 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)
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 df_scud_today is not None and not df_scud_today.empty:
export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx")
# [Этап 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')}")
# Генерация Упрощенного отчета за вчера
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)
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}")
# [Этап 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')}")
# Если запуск ночной (после 22:00) или закрывающий смену — зачищаем дневные срезы
if now.hour >= 22 and not args.skip_export:
purged = purge_day_intermediate_snapshots(today_str)
if purged > 0:
print(f"[🧹] Ночная очистка: удалено {purged} строк дневных срезов за {today_str}. Итоговый Y-срез сохранен.")
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today)
absent_explained = df_merged_today[
(df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].notna()) &
(~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)
]
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=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
)
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
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}")
# 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ
if args.snapshot or args.skip_export:
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param)
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
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
print(summary_md)
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")
print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}")
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
# Гарантированная загрузка кадров 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
)
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
)
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
# ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ
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())
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_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,
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
)
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)
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
print(report_text)
else:
print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
if __name__ == "__main__":
+197 -92
View File
@@ -3,11 +3,19 @@
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Нативный оркестратор диалога, диспетчер Function Calling,
передача активных срезов в контекст модели и терминальные вызовы.
ROLE: Нативный оркестратор диалога, диспетчер инструментов (Function Calling)
и управление сессионными стейтами через ContextManager.
AI-CONTEXT-ANCHORS:
- ANCHOR[AGENT_IMPORTS]: Системные и доменные импорты.
- ANCHOR[AGENT_MAIN_PIPELINE]: Основная точка входа process_chat_message.
- ANCHOR[AGENT_SYSTEM_PROMPT]: Формирование динамического контекста.
- ANCHOR[AGENT_TOOL_DISPATCHER]: Исполнение нативных вызовов инструментов.
- ANCHOR[AGENT_TOPIC_DRIFT]: Защита контекста и обработка свободных тем.
===============================================================================
"""
# ANCHOR[AGENT_IMPORTS]
import sys
import json
import logging
@@ -19,7 +27,7 @@ from .db_tools import (
db_apply_prompt_node_action,
db_get_tool_action,
db_get_tasks,
db_update_task_details,
db_update_task_status,
db_delete_task,
db_add_task,
db_tasks_edit,
@@ -62,6 +70,7 @@ if not logger.handlers:
logger.addHandler(handler)
# ANCHOR[AGENT_MAIN_PIPELINE]
def process_chat_message(
user_id: int,
user_message: str,
@@ -81,7 +90,7 @@ def process_chat_message(
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
# 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM
# 1. Быстрый перехват строго системных кнопок UI (подтверждение превью промпта)
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
@@ -90,6 +99,7 @@ def process_chat_message(
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_history = db_get_chat_history(session_id, limit=20)
# ANCHOR[AGENT_SYSTEM_PROMPT]
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
@@ -101,63 +111,52 @@ def process_chat_message(
active_state_context = ""
if current_state_type == "PROMPT_PREVIEW":
active_state_context = "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n- Открыт предпросмотр изменений промпта.\n"
active_state_context = (
"\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n"
"- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n"
)
elif current_state_type == "PROMPT_FOLLOWUP":
active_state_context = (
"\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
"- Оператор просматривает или редактирует системный промпт.\n"
"- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
"- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n"
"- Запрещено путать ADD и DELETE.\n"
)
elif current_state_type == "SNAPSHOTS_VIEW":
active_date = state_data.get("query_date", "выбранную дату")
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {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"
)
active_state_context = (
f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n"
f"- Сейчас на экране отображаются снапшоты за {active_date}.\n"
f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n"
f"- Если оператор запрашивает ДРУГУЮ дату или день недели, отличную от {active_date} (например: 'за вчера', 'а за 13.08', 'покажи за сегодня') — "
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n"
f"- Запрещено генерировать текст за другую дату по памяти.\n"
)
elif current_state_type == "SNAPSHOT_DELETE_CONFIRM":
active_state_context = (
"\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n"
)
elif current_state_type == "TASK_DELETE_CONFIRM":
active_state_context = (
"\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n"
)
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n"
f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\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"2. Для получения данных всегда вызывай соответствующий инструмент:\n"
f" - Снапшоты и срезы логов СКУД за любые даты и дни недели -> db_get_snapshots\n"
f" - Удаление снапшотов -> db_delete_snapshots\n"
f" - Задачи и бэклог (просмотр, создание, смена статуса, удаление, экспорт) -> db_get_tasks, db_tasks_edit\n"
f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
f" - База знаний и регламенты -> db_get_rules\n"
f" - Аномалии СКУД/1С -> db_get_anomalies\n"
f" - База знаний -> db_get_rules\n"
f" - Статистика БД -> db_get_stats\n"
f" - Справка -> db_get_reference\n"
f"3. ЗАДАЧИ:\n"
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"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
@@ -166,6 +165,7 @@ def process_chat_message(
user_msg_object = {"role": "user", "content": full_user_content}
# ANCHOR[AGENT_TOOL_DISPATCHER]
try:
if image_b64:
user_msg_object["images"] = [image_b64]
@@ -201,7 +201,7 @@ def process_chat_message(
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
# Ротация контекста
# Ротация контекста: закрываем старую сессию инструмента
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
session_state = None
mark_last_user_message_ephemeral(session_id)
@@ -217,7 +217,7 @@ def process_chat_message(
}
# 2. Единый диспетчер задач
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]:
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
action = fn_args.get("action", "UPDATE").upper()
if fn_name == "db_add_task": action = "ADD"
elif fn_name == "db_delete_task": action = "DELETE"
@@ -239,7 +239,7 @@ def process_chat_message(
elif action == "EXPORT":
export_res = db_export_tasks_markdown(
user_id=user_id,
filename=fn_args.get("filename") or "ROADMAP.md",
filename=fn_args.get("filename"),
status_filter=fn_args.get("status")
)
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
@@ -277,46 +277,96 @@ def process_chat_message(
# 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"
"Вы можете добавить, отредактировать или удалить любой пункт."
)
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
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",
return reply_text, db_get_chat_history(session_id), None
elif fn_name == "db_prompt_node_edit":
action = str(fn_args.get("action", "ADD")).upper()
try:
sec_id = int(str(fn_args.get("section_id", 3)).strip())
except Exception:
sec_id = 3
try:
itm_id = int(str(fn_args.get("item_id", 1)).strip())
except Exception:
itm_id = 1
content = str(fn_args.get("content", "")).strip()
baseline_prompt = db_get_active_system_prompt()
with get_db_connection() as temp_conn:
temp_cursor = temp_conn.cursor()
temp_cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = 'main_agent' AND is_active = 1
ORDER BY section_id, item_id
""")
existing_nodes = temp_cursor.fetchall()
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} if action == "DELETE" else dict(nodes_dict)
if action != "DELETE":
nodes_dict_for_draft[(sec_id, itm_id)] = content
draft_lines = []
curr_sec = None
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
if i_id == 0:
if curr_sec is not None: draft_lines.append("")
draft_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
draft_lines.append(f" {s_id}.{i_id}. {txt}")
merged_prompt = "\n".join(draft_lines)
diff_lines = []
curr_sec = None
display_nodes = dict(nodes_dict)
if action != "DELETE":
display_nodes[(sec_id, itm_id)] = content
for (s_id, i_id), txt in sorted(display_nodes.items()):
if i_id == 0:
if curr_sec is not None: diff_lines.append("")
diff_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
if s_id == sec_id and i_id == itm_id:
line_str = f' <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>' if action == "DELETE" else 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)
diff_html = "\n".join(diff_lines)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": merged_prompt,
"action": action,
"section_id": sec_id,
"item_id": itm_id,
"content": content,
"idle_turns": 0
})
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": merged_prompt,
"baseline_prompt": baseline_prompt,
"buttons": [
{"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"},
{"label": "База знаний", "value": "покажи правила компании", "style": "secondary"}
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "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. Снапшоты СКУД
# 4. Снапшоты СКУД
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", "выбранную дату")
@@ -372,14 +422,41 @@ def process_chat_message(
"type": "SNAPSHOTS_CARD",
"data": updated_snapshots_res
}
elif fn_name == "db_delete_snapshots":
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
raw_ids = fn_args.get("snapshot_ids") or ([raw_id] if raw_id else [])
# Защита: итоговые Y-срезы удалять запрещено
safe_ids = [s for s in raw_ids if not str(s).startswith("Y")]
if not safe_ids:
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
# 6. Прочие сервисные инструменты
# Прямое выполнение удаления в SQLite без лишних текстовых подтверждений
del_res = db_delete_snapshots(snapshot_ids=safe_ids)
# Получаем свежий реестр за ту же дату
query_date = fn_args.get("day_str", "")
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_snapshots_res
}
# 5. Сервисные запросы
elif fn_name == "db_get_current_server_time":
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
elif fn_name == "db_get_stats":
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
elif fn_name == "db_get_anomalies":
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
elif fn_name == "db_get_rules":
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
elif fn_name == "db_get_reference":
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
else:
@@ -391,15 +468,43 @@ def process_chat_message(
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]:
if final_content.lower().startswith(artifact):
final_content = final_content[len(artifact):].lstrip(",.!?:; -")
is_ephem = 1 if fn_name in ["db_get_snapshots", "db_get_system_prompt", "db_prompt_node_edit"] else 0
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_ephem)
return final_content, db_get_chat_history(session_id), None
# Свободный диалог (Topic Drift)
# ANCHOR[AGENT_TOPIC_DRIFT]
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]:
if final_reply.lower().startswith(artifact):
final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
action_payload = None
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]:
idle_turns += 1
if idle_turns > 3:
db_clear_session_state(session_id)
db_purge_ephemeral_messages(session_id)
action_payload = None
session_state = None
elif idle_turns == 3:
tool_action = db_get_tool_action("db_confirm_prompt_preview")
guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?"
buttons = tool_action.get("buttons", []) if tool_action else []
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons}
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns})
else:
db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns})
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), None
return final_reply, db_get_chat_history(session_id), action_payload
except Exception as ex:
logger.exception(f"Непредвиденная ошибка агента: {ex}")
+64 -294
View File
@@ -1,317 +1,87 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью.
===============================================================================
ROLE: Детерминированный технический конвейер (НЕ для ИИ-размышлений).
Выполняет только кнопки подтверждения для удаления задач, снапшотов и промптов.
"""
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)
from llm.db_tools import (
db_delete_task,
db_get_tasks,
db_delete_snapshots,
db_get_snapshots,
db_add_system_prompt,
db_apply_prompt_node_action
)
from .context_manager import close_tool_session_and_cleanup, save_tool_interaction, save_dialog_interaction
def handle_fast_path_intercept(
session_id: str,
user_message: str,
full_user_content: str,
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 = {}
msg = user_message.strip().lower()
# -------------------------------------------------------------------------
# 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА
# -------------------------------------------------------------------------
# ⭐️ Управление системным промптом (Превью)
if state_type == "PROMPT_PREVIEW":
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
state_data = session_state.get("data_json") or {}
draft_text = state_data.get("draft_text", "")
action = state_data.get("action")
sec_id = state_data.get("section_id")
itm_id = state_data.get("item_id")
content = state_data.get("content", "")
if is_confirm:
draft_text = state_data.get("draft_text", "")
if draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
if msg in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
if action == "MANUAL_EDIT" or not action or sec_id is None:
if draft_text:
db_add_system_prompt("main_agent", draft_text)
else:
db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
db_clear_session_state(session_id)
close_tool_session_and_cleanup(session_id, "PROMPT_APPLIED_SUCCESSFULLY")
reply_text = "✅ Изменения системного промпта успешно применены в базе данных."
save_tool_interaction(session_id, full_user_content, reply_text)
return reply_text, [], None
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 msg in ["отмена", "отменить", "отклонить"]:
close_tool_session_and_cleanup(session_id, "PROMPT_PREVIEW_CANCELLED")
reply_text = "❌ Изменения системного промпта отменены."
save_dialog_interaction(session_id, full_user_content, reply_text)
return reply_text, [], None
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
db_clear_session_state(session_id)
# Удаление снапшотов
elif state_type == "SNAPSHOT_DELETE_CONFIRM":
state_data = session_state.get("data_json", {})
ids = state_data.get("snapshot_ids", [])
query_date = state_data.get("query_date", "")
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
if msg.startswith("подтверждаю удаление снапшотов"):
db_delete_snapshots(snapshot_ids=ids)
close_tool_session_and_cleanup(session_id, "SNAPSHOTS_DELETED")
updated_data = db_get_snapshots(session_id=session_id, date_str=query_date)
return f"✅ Удалено снапшотов: {len(ids)}", [], {"type": "SNAPSHOTS_CARD", "data": updated_data}
elif msg == "отмена":
close_tool_session_and_cleanup(session_id, "SNAPSHOT_CANCELLED")
updated_data = db_get_snapshots(session_id=session_id, date_str=query_date)
return "Удаление отменено.", [], {"type": "SNAPSHOTS_CARD", "data": updated_data}
# -------------------------------------------------------------------------
# 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
# Удаление задач
elif state_type == "TASK_DELETE_CONFIRM":
task_id = session_state.get("data_json", {}).get("task_id")
if msg.startswith("подтверждаю удаление задачи"):
db_delete_task(1, str(task_id))
close_tool_session_and_cleanup(session_id, "TASK_DELETED")
return f"Задача #{task_id} удалена.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)}
elif msg == "отмена":
close_tool_session_and_cleanup(session_id, "TASK_CANCELLED")
return "Удаление отменено.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)}
return None
+1 -1
View File
@@ -14,7 +14,7 @@ from typing import Dict, Any, Optional
logger = logging.getLogger("OLLAMA_CLIENT")
OLLAMA_URL = "http://10.121.17.227:11434/api/chat"
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0"
+74
View File
@@ -0,0 +1,74 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/prompt_merger.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок.
===============================================================================
"""
# ANCHOR[PROMPT_MERGER_IMPORTS]
import re
import logging
logger = logging.getLogger("PROMPT_MERGER")
# ANCHOR[PROMPT_MERGE_ENGINE]
def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str:
"""
Вычисляет результирующий чистый текст промпта для записи в БД.
"""
user_msg_lower = user_message.lower()
# 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 ""
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
return "\n".join(new_lines)
# 2. Сценарий добавления или замены пункта
if 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(" .:")
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}")
return "\n".join(new_lines)
return proposed_text or current_prompt
def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str:
"""
Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind.
"""
old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()}
diff_lines = []
for line in new_prompt.splitlines():
if line.strip() and line.strip() not in old_lines_set:
# Выделяем новую или измененную строку красным цветом
diff_lines.append(f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{line}</span>')
else:
diff_lines.append(line)
return "\n".join(diff_lines)
+16 -15
View File
@@ -3,34 +3,35 @@
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Базовая санитарная очистка вывода и тегов инструментов.
ROLE: Базовая санитарная очистка артефактов без эвристик и регулярных выражений.
AI-CONTEXT-ANCHORS:
- ANCHOR[CLEAN_RAW_TOOLS]: Очистка строковых тегов.
- ANCHOR[PASS_THROUGH_TOOLS]: Чистый проходной интерфейс инструментов.
===============================================================================
"""
import re
import logging
# ANCHOR[CLEAN_RAW_TOOLS]
from typing import List, Dict, Any
logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str:
"""Удаляет сырые теги вызова инструментов и системный шум."""
"""Удаляет только технические теги разметки, если они попали в текст."""
if not text:
return ""
cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
return cleaned.strip()
return text.replace("<tool_call>", "").replace("</tool_call>", "").strip()
def clean_output(text: str) -> str:
"""Очищает маркеры форматирования."""
return text.strip() if text else ""
"""Возвращает текст ответа без изменения смысла."""
if not text:
return ""
return text.strip()
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# ANCHOR[PASS_THROUGH_TOOLS]
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Модель управляется через TOOLS_SCHEMA и системный контекст.
Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
Чистый сквозной проход: все решения принимает исключительно языковая модель.
"""
return existing_tool_calls
return tool_calls
+13 -4
View File
@@ -1,8 +1,17 @@
"""
===============================================================================
FILE: modules/web_api/llm/db/connection.py
ROLE: Реэкспорт единого подключения к БД из core.connection.
===============================================================================
"""
import os
import sqlite3
from core.connection import get_connection as get_db_connection, DB_PATH
# Динамический путь к общей БД в корне проекта
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
+18 -49
View File
@@ -7,7 +7,7 @@ from .connection import get_db_connection
def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral: int = 0) -> None:
"""Сохраняет сообщение в БД (is_ephemeral=1 для временных служебных шагов, 0 для постоянных)."""
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO chat_messages (session_id, role, content, is_ephemeral)
@@ -17,19 +17,20 @@ def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral:
conn.close()
def db_get_chat_history(session_id: str, limit: int = 50) -> list:
"""Возвращает историю сообщений диалога для сессии."""
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT role, content, is_ephemeral, created_at
FROM chat_messages
WHERE session_id = ?
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_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
"""Получает последние сообщения истории диалога в хронологическом порядке."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT role, content
FROM chat_messages
WHERE session_id = ?
ORDER BY id DESC
LIMIT ?
""", (session_id, limit))
rows = cursor.fetchall()
conn.close()
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
def db_purge_ephemeral_messages(session_id: str) -> int:
@@ -37,7 +38,7 @@ def db_purge_ephemeral_messages(session_id: str) -> int:
Физически удаляет все временные служебные сообщения выбранной сессии
после завершения сценария работы с инструментом.
"""
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1", (session_id,))
deleted = cursor.rowcount
@@ -48,40 +49,8 @@ def db_purge_ephemeral_messages(session_id: str) -> int:
def db_clear_chat_history(session_id: str) -> None:
"""Полная очистка всех сообщений сессии."""
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
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
conn.close()
+12 -12
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger("DB_PROMPTS")
def init_prompt_nodes_table():
"""Создает реляционную таблицу узлов промпта и заполняет базовыми данными."""
with get_db_connection(row_factory=True) as conn:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS system_prompt_nodes (
@@ -40,7 +40,7 @@ def init_prompt_nodes_table():
# Раздел 1
(1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"),
(1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."),
(1, 2, "Консультация по внутренним регламентам компании (через db_get_rules). Вопросы по ТК РФ и законам поясняй напрямую."),
(1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."),
(1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."),
(1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."),
(1, 5, "Поддержка диалога с операторами и администраторами системы."),
@@ -73,7 +73,7 @@ def init_prompt_nodes_table():
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:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT section_id, item_id, content
@@ -104,7 +104,7 @@ def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str:
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:
with get_db_connection() as conn:
cursor = conn.cursor()
action_clean = action.upper()
if action_clean in ["ADD", "UPDATE"]:
@@ -125,7 +125,7 @@ def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, cont
def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT tool_name, category, bypass_llm, success_template,
@@ -144,7 +144,7 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
def db_get_rules() -> List[Dict[str, Any]]:
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
rows = cursor.fetchall()
@@ -154,7 +154,7 @@ def db_get_rules() -> List[Dict[str, Any]]:
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
"""Сохраняет состояние сессии в SQLite."""
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
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("""
@@ -170,7 +170,7 @@ def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,))
row = cursor.fetchone()
@@ -187,7 +187,7 @@ def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
def db_clear_session_state(session_id: str) -> None:
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
conn.commit()
@@ -195,7 +195,7 @@ def db_clear_session_state(session_id: str) -> None:
def db_get_stats() -> Dict[str, Any]:
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks']
stats = {}
@@ -210,7 +210,7 @@ def db_get_stats() -> Dict[str, Any]:
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
params = []
@@ -226,7 +226,7 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection(row_factory=True)
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT category, title, example_prompt, description FROM system_reference"
params = []
+112
View File
@@ -0,0 +1,112 @@
"""
===============================================================================
FILE: modules/web_api/llm/db/db_snapshots.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / db
ROLE: Выборка, фильтрация и пакетное удаление срезов логов СКУД в SQLite.
AI-CONTEXT-ANCHORS:
- ANCHOR[DB_GET_SNAPSHOTS]: Выборка снапшотов с нормализацией дат.
- ANCHOR[DB_DEL_SNAPSHOTS]: Удаление снапшотов по ID, списку ID или за дату.
===============================================================================
"""
import json
import re
from typing import Dict, Any, Optional, List
from .connection import get_db_connection
from .db_prompts import db_set_session_state
from ..core.calendar_utils import parse_relative_date_ru
# ANCHOR[DB_GET_SNAPSHOTS]
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 = []
clean_date = date_str.strip() if date_str else ""
if not clean_date or not re.search(r'\d{2}\.\d{2}\.\d{4}', clean_date):
if original_user_message:
clean_date = parse_relative_date_ru(original_user_message)
if clean_date and re.search(r'\d{2}\.\d{2}\.\d{4}', clean_date):
iso_date = clean_date
compact_date = clean_date.replace(".", "")
if "." in clean_date:
parts = clean_date.split(".")
if len(parts) == 3:
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
compact_date = f"{parts[2]}{parts[1]}{parts[0]}"
query += """
WHERE log_date = ?
OR snapshot_time LIKE ?
OR snapshot_id LIKE ?
OR snapshot_id LIKE ?
"""
params.extend([clean_date, f"{iso_date}%", f"{compact_date}-%", f"Y{compact_date}-%"])
query += """
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC, snapshot_id DESC
LIMIT 50
"""
cursor.execute(query, params)
rows = cursor.fetchall()
snapshots = [dict(r) for r in rows]
result_data = {
"query_date": clean_date or "все",
"snapshots_count": len(snapshots),
"snapshots": snapshots
}
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=result_data)
conn.close()
return result_data
# ANCHOR[DB_DEL_SNAPSHOTS]
def db_delete_snapshots(
snapshot_id: Optional[str] = None,
snapshot_ids: Optional[List[str]] = None,
day_str: Optional[str] = None
) -> Dict[str, Any]:
"""Удаляет один или группу дневных снапшотов с защитой итоговых Y-снапшотов."""
conn = get_db_connection()
cursor = conn.cursor()
deleted = 0
if snapshot_ids and isinstance(snapshot_ids, list):
# Исключаем любые итоговые снапшоты, начинающиеся с Y
safe_ids = [s.strip() for s in snapshot_ids if s and not str(s).strip().startswith("Y")]
if safe_ids:
placeholders = ",".join(["?"] * len(safe_ids))
cursor.execute(f"DELETE FROM scud_logs WHERE snapshot_id IN ({placeholders})", safe_ids)
deleted = cursor.rowcount
elif snapshot_id:
clean_id = str(snapshot_id).strip()
if clean_id.startswith("Y"):
conn.close()
return {"status": "error", "message": f"Итоговый срез [{clean_id}] защищен от удаления."}
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (clean_id,))
deleted = cursor.rowcount
elif day_str:
cursor.execute("DELETE FROM scud_logs WHERE (log_date = ? OR snapshot_id LIKE ?) AND snapshot_id NOT LIKE 'Y%'", (day_str, f"%{day_str.replace('.', '')}%"))
deleted = cursor.rowcount
else:
conn.close()
return {"status": "error", "message": "Не указаны идентификаторы для удаления."}
conn.commit()
conn.close()
return {"status": "success", "deleted_records": deleted, "message": f"Успешно удалено записей: {deleted}"}
+297
View File
@@ -0,0 +1,297 @@
"""
===============================================================================
FILE: modules/web_api/llm/db/db_tasks.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / db
ROLE: Комплексное управление задачами, единый диспетчер db_tasks_edit
и генерация структурированных отчетов в Markdown.
===============================================================================
"""
import uuid
import logging
import os
import re
from datetime import datetime
from typing import List, Dict, Any, Optional
from .connection import get_db_connection
logger = logging.getLogger("DB_TASKS")
def normalize_task_id(task_id_input: str) -> str:
"""Нормализует идентификатор задачи к формату TASK-XX."""
if not task_id_input:
return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "")
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, status: Optional[str] = None) -> List[Dict[str, Any]]:
"""Получает список всех задач пользователя."""
conn = get_db_connection()
cursor = conn.cursor()
if status and status.upper() != "ALL":
target_status = status.upper()
if target_status in ["PROGRESS", "В РАБОТЕ"]: target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]: target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ"]: target_status = "BACKLOG"
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED'))
ORDER BY id DESC
""", (user_id, target_status, target_status))
else:
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
def db_add_task(
user_id: int,
module: str,
title: str,
priority: str = "MEDIUM",
due_date: Optional[str] = None,
status: str = "BACKLOG"
) -> Dict[str, Any]:
"""Добавление новой задачи со статусом по умолчанию BACKLOG (В планах)."""
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}"
target_status = status.upper() if status else "BACKLOG"
if target_status in ["PROGRESS", "В РАБОТЕ"]: target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]: target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]: target_status = "BACKLOG"
cursor.execute("""
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id))
conn.commit()
conn.close()
return {"status": "success", "task_id": new_task_id, "message": f"Задача #{max_id + 1} создана и добавлена в планы"}
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
"""Быстрое обновление статуса задачи."""
return db_update_task_details(user_id=user_id, task_id=task_id, status=status, due_date=due_date)
def db_update_task_details(
user_id: int,
task_id: str,
title: Optional[str] = None,
priority: Optional[str] = None,
status: Optional[str] = None,
due_date: Optional[str] = None
) -> Dict[str, Any]:
"""Комплексное обновление любых параметров задачи."""
conn = get_db_connection()
cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id)
updates = []
params = []
if title is not None and title.strip():
updates.append("title = ?")
params.append(title.strip())
if priority is not None and priority.strip():
updates.append("priority = ?")
params.append(priority.strip().upper())
if status is not None and status.strip():
target_status = status.strip().upper()
if target_status in ["PROGRESS", "В РАБОТЕ"]: target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]: target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ"]: target_status = "BACKLOG"
updates.append("status = ?")
params.append(target_status)
if due_date is not None:
updates.append("due_date = ?")
params.append(due_date.strip() if due_date.strip() else None)
if not updates:
conn.close()
return {"status": "success", "message": "Нет данных для обновления"}
params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id])
sql = f"""
UPDATE tasks
SET {', '.join(updates)}
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
"""
cursor.execute(sql, params)
if cursor.rowcount == 0:
conn.close()
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
conn.commit()
conn.close()
return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"}
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
"""Удаление задачи."""
conn = get_db_connection()
cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id)
cursor.execute("""
DELETE FROM tasks
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (clean_num, formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0:
conn.close()
return {"error": f"Задача {task_id} не найдена"}
conn.commit()
conn.close()
return {"status": "success", "message": f"Задача #{task_id} удалена"}
def db_export_tasks_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]:
"""Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/."""
tasks = db_get_tasks(user_id)
if not tasks:
return {"status": "error", "message": "Список задач пуст, экспорт отменен"}
# Фильтрация по статусу
if status_filter and status_filter.upper() != "ALL":
tgt = status_filter.upper()
if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]]
elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]]
elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]]
if not tasks:
return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"}
# Имя файла
target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md"
if not target_filename.endswith(".md"):
target_filename = f"{target_filename}.md"
now_dt = datetime.now()
now_str = now_dt.strftime("%Y-%m-%d %H:%M")
modules: Dict[str, List[Dict[str, Any]]] = {}
for t in tasks:
mod = t.get("module") or "general"
modules.setdefault(mod, []).append(t)
md_lines = [
"# 🗺️ Дорожная карта задач проекта (ROADMAP)\n",
f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n",
"---\n"
]
for mod_name, mod_tasks in sorted(modules.items()):
md_lines.append(f"## Модуль `{mod_name}`\n")
for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)):
status = str(t.get("status", "BACKLOG")).upper()
is_done = status in ["COMPLETED", "DONE"]
is_progress = status in ["IN_PROGRESS", "PROGRESS"]
check_box = "[x]" if is_done else "[ ]"
t_id = t.get("id")
title = t.get("title", "Без названия")
prio = t.get("priority", "MEDIUM")
due = f" *(срок: {t['due_date']})*" if t.get("due_date") else ""
status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "")
md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}")
md_lines.append("\n---\n")
content = "\n".join(md_lines)
# Точный путь к корню scud_ai/output/web/tasks_export/
current_file_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.abspath(os.path.join(current_file_dir, "../../../../"))
tool_dir = os.path.join(root_dir, "output", "web", "tasks_export")
os.makedirs(tool_dir, exist_ok=True)
try:
from routers.files import purge_old_tool_sessions
purge_old_tool_sessions(tool_dir)
except Exception:
pass
session_token = uuid.uuid4().hex[:8]
session_dir = os.path.join(tool_dir, session_token)
os.makedirs(session_dir, exist_ok=True)
filepath = os.path.join(session_dir, target_filename)
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
logger.info(f"Файл успешно создан: {filepath}")
return {
"status": "success",
"filename": target_filename,
"filepath": filepath,
"download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}",
"tasks_count": len(tasks),
"message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})."
}
def db_tasks_edit(
user_id: int,
action: str,
task_id: Optional[str] = None,
title: Optional[str] = None,
priority: Optional[str] = "MEDIUM",
status: Optional[str] = None,
module: Optional[str] = "general",
due_date: Optional[str] = None,
filename: Optional[str] = "ROADMAP.md"
) -> Dict[str, Any]:
"""Единый консолидированный диспетчер операций над задачами."""
act = action.strip().upper()
if act == "ADD":
if not title:
return {"status": "error", "message": "Для создания задачи требуется указать title"}
return db_add_task(
user_id=user_id,
module=module or "general",
title=title,
priority=priority or "MEDIUM",
due_date=due_date,
status=status or "BACKLOG"
)
elif act == "UPDATE":
if not task_id:
return {"status": "error", "message": "Для обновления требуется указать task_id"}
return db_update_task_details(user_id=user_id, task_id=str(task_id), title=title, priority=priority, status=status, due_date=due_date)
elif act == "DELETE":
if not task_id:
return {"status": "error", "message": "Для удаления требуется указать task_id"}
return db_delete_task(user_id=user_id, task_id=str(task_id))
elif act == "EXPORT":
return db_export_tasks_markdown(user_id=user_id, filename=filename or "ROADMAP.md")
return {"status": "error", "message": f"Неизвестное действие action='{action}'"}
+28 -83
View File
@@ -3,97 +3,42 @@
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.
ROLE: Фасадная точка доступа ко всем функциям базы данных SQLite.
===============================================================================
"""
# 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
from .db.connection import DB_PATH, get_db_connection
from .db.db_prompts import (
db_get_active_system_prompt,
db_add_system_prompt,
db_apply_prompt_node_action,
db_get_tool_action,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
db_get_rules,
db_get_stats,
db_get_anomalies,
db_get_reference
)
# Защитный алиас для обратной совместимости
db_update_task_status = db_update_task_details
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
from services.tasks.repository import normalize_task_id
# Домен: Системный промпт
from services.prompts.service import (
get_active_system_prompt as db_get_active_system_prompt,
apply_prompt_action as db_apply_prompt_node_action,
save_full_prompt_draft,
create_prompt_preview
)
# Домен: База знаний
from services.knowledge.service import (
get_rules as db_get_rules,
add_rule as db_add_rule
)
# Чат, сессии, статистика
from .db.db_chat import (
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_clear_chat_history
)
from .db.db_prompts import (
db_get_tool_action,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
db_get_stats,
db_get_anomalies,
db_get_reference
from .db.db_tasks import (
normalize_task_id,
db_get_tasks,
db_add_task,
db_update_task_status,
db_update_task_details,
db_delete_task,
db_export_tasks_markdown,
db_tasks_edit
)
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
"""Совместимая обертка для сохранения системного промпта."""
if draft_text is not None:
save_full_prompt_draft(draft_text, prompt_name=name_or_text)
else:
save_full_prompt_draft(name_or_text, prompt_name="main_agent")
def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]:
"""Совместимый фасад выборки снапшотов с сохранением стейта сессии."""
from services.snapshots.service import get_snapshots_registry
from .core.calendar_utils import parse_relative_date_ru
clean_date = (date_str or "").strip()
if not clean_date and original_user_message:
clean_date = parse_relative_date_ru(original_user_message)
res = get_snapshots_registry(date_str=clean_date if clean_date else None)
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res)
return res
def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]:
"""Совместимый фасад безопасного удаления снапшотов."""
from services.snapshots.service import delete_snapshots_safely
target_ids = []
if snapshot_ids:
target_ids.extend(snapshot_ids)
if snapshot_id:
if isinstance(snapshot_id, str) and "," in snapshot_id:
target_ids.extend([s.strip() for s in snapshot_id.split(",")])
else:
target_ids.append(snapshot_id)
return delete_snapshots_safely(snapshot_ids=target_ids)
from .db.db_snapshots import (
db_get_snapshots,
db_delete_snapshots
)
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
+18 -42
View File
@@ -19,37 +19,32 @@ TOOLS_SCHEMA = [
"function": {
"name": "db_prompt_node_edit",
"description": (
"Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
"Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
"При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
"Управление элементами системного промпта (добавление, изменение, удаление). "
"Если пользователь пишет 'удали 2.9' или 'удали пункт 2.9', вызывай action='DELETE', section_id=2, item_id=9. "
"Действие action может быть ADD, EDIT или DELETE."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
"description": "Тип действия"
"enum": ["ADD", "EDIT", "DELETE"],
"description": "Действие: ADD (добавить), EDIT (изменить), DELETE (удалить)"
},
"section_id": {
"type": "integer",
"description": "Номер раздела (например 1)"
"description": "Номер раздела из точки (например, 2 из 2.9)"
},
"item_id": {
"type": "integer",
"description": "Номер пункта (например 8)"
},
"nodes_list": {
"type": "array",
"items": {"type": "string"},
"description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
"description": "Номер пункта из точки (например, 9 из 2.9)"
},
"content": {
"type": "string",
"description": "Текст пункта"
"description": "Текст пункта (для DELETE пустая строка)"
}
},
"required": ["action"]
"required": ["action", "section_id", "item_id"]
}
}
},
@@ -69,18 +64,14 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_get_tasks",
"description": (
"Просмотр реестра задач и бэклога текущего пользователя.\n"
"Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n"
"Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}."
),
"description": "Просмотр реестра задач и бэклога текущего пользователя.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
"description": "Опциональный фильтр статуса задач (по умолчанию ALL)"
"description": "Опциональный фильтр статуса задач"
}
}
}
@@ -90,14 +81,7 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_tasks_edit",
"description": (
"Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n"
"СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n"
"- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n"
"- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n"
"- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n"
"- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент."
),
"description": "Единый инструмент управления задачами: создание (ADD), изменение статуса/дедлайна (UPDATE), удаление (DELETE) и экспорт задач в Markdown-файл (EXPORT).",
"parameters": {
"type": "object",
"properties": {
@@ -108,7 +92,7 @@ TOOLS_SCHEMA = [
},
"task_id": {
"type": "string",
"description": "Номер задачи (например: '37')"
"description": "Номер задачи (для UPDATE и DELETE, например: '35')"
},
"title": {
"type": "string",
@@ -116,8 +100,8 @@ TOOLS_SCHEMA = [
},
"status": {
"type": "string",
"enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
"description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
"enum": ["IN_PROGRESS", "COMPLETED", "PLANNED"],
"description": "Статус задачи"
},
"priority": {
"type": "string",
@@ -146,11 +130,7 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_get_snapshots",
"description": (
"Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
"ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
"КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
),
"description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
"parameters": {
"type": "object",
"properties": {
@@ -166,21 +146,17 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "Удаление дневных снапшотов СКУД по идентификатору или дате.",
"description": "Удаление снапшотов СКУД по идентификатору или дате.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {
"type": "string",
"description": "Идентификатор или список идентификаторов через запятую"
"description": "Идентификатор конкретного снапшота"
},
"day_str": {
"type": "string",
"description": "Дата всех снапшотов за день"
},
"confirmed": {
"type": "boolean",
"description": "Флаг окончательного подтверждения удаления пользователем"
}
}
}
-26
View File
@@ -16,29 +16,16 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR)
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
for p in [ROOT_DIR, CURRENT_DIR]:
if p not in sys.path:
sys.path.insert(0, p)
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from routers.manual_absences import router as manual_absences_router
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
# ANCHOR[APP_CONFIG]
logging.basicConfig(
@@ -68,13 +55,6 @@ 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)
# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
@@ -94,12 +74,6 @@ async def favicon():
@app.get("/{file_path:path}")
def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/")
# Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов
forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"]
if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns):
raise HTTPException(status_code=403, detail="Access denied")
target = os.path.join(STATIC_DIR, clean_path)
if os.path.isfile(target):
+16 -21
View File
@@ -5,6 +5,8 @@ ROLE: Аутентификация, валидация JWT-токенов и у
===============================================================================
"""
# ANCHOR[AUTH_ROUTER_IMPORTS]
import sqlite3
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
@@ -15,7 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from core.connection import get_connection
from llm.db_tools import DB_PATH
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
@@ -25,10 +27,11 @@ security = HTTPBearer()
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# ANCHOR[AUTH_DB_HELPERS]
def get_db():
return get_connection(row_factory=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = {
@@ -39,7 +42,6 @@ def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
@@ -56,44 +58,36 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
headers={"WWW-Authenticate": "Bearer"},
)
# ANCHOR[AUTH_SCHEMAS]
class AuthRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
# ANCHOR[AUTH_ENDPOINTS]
@router.post("/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, full_name, is_admin FROM users WHERE username = ?", (username,))
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}")
# Возвращаем 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
}
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@router.post("/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
@@ -114,4 +108,5 @@ def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = D
conn.commit()
conn.close()
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"}
+74 -129
View File
@@ -1,148 +1,93 @@
"""
===============================================================================
FILE: modules/web_api/routers/chat.py
ROLE: Роутер чата с чистым разделением:
- Диалог и команды СКУД/1С (через agent.py).
- Парсинг и извлечение документов без обрезания (через file_parser.py).
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers
ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков.
===============================================================================
"""
import os
import shutil
import logging
from typing import Optional
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
# ANCHOR[CHAT_ROUTER_IMPORTS]
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from pydantic import BaseModel
from .auth import get_current_user
from llm.agent import process_chat_message
from llm.file_parser import parse_uploaded_file
from config import BASE_DIR
from llm.file_parser import extract_text_from_file
from llm.db_tools import db_set_session_state, db_get_session_state
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)
router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
class ChatMessageRequest(BaseModel):
message: str
session_id: Optional[str] = "web_session_main"
user_id: Optional[int] = 1
# ANCHOR[DRAFT_SCHEMA]
class UpdateDraftRequest(BaseModel):
session_id: str
draft_text: str
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)
# ANCHOR[CHAT_ENDPOINTS]
@router.post("")
async def chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None),
current_user: Dict[str, Any] = Depends(get_current_user)
):
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)
"""Диалог авторизованного пользователя с агентом."""
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)
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,
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}
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
@router.post("/guest")
async def guest_chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None)
):
"""Гостевой диалог (user_id=0)."""
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}
@router.post("/draft")
def update_draft_endpoint(
req: UpdateDraftRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Обновляет черновик системного промпта напрямую из интерактивной онлайн-формы."""
state = db_get_session_state(req.session_id)
if not state or state.get("state_type") != "PROMPT_PREVIEW":
raise HTTPException(status_code=400, detail="Нет активного превью для редактирования")
# Сохраняем чистый текст с пометкой MANUAL_EDIT
db_set_session_state(req.session_id, "PROMPT_PREVIEW", {
"draft_text": req.draft_text.strip(),
"action": "MANUAL_EDIT",
"section_id": None,
"item_id": None,
"content": ""
})
return {"status": "success", "message": "Черновик успешно обновлен в сессии"}
-50
View File
@@ -1,50 +0,0 @@
"""
===============================================================================
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
@@ -1,34 +0,0 @@
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"}
+10 -52
View File
@@ -1,7 +1,8 @@
"""
===============================================================================
FILE: modules/web_api/routers/files.py
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен.
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
через изолированные UUID-директории инструментов.
===============================================================================
"""
@@ -9,7 +10,6 @@ import os
import time
import shutil
import urllib.parse
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
@@ -17,17 +17,13 @@ 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
SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
def purge_old_tool_sessions(tool_dir_path: str):
"""Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
if not os.path.exists(tool_dir_path):
return
now = time.time()
@@ -44,6 +40,9 @@ def purge_old_tool_sessions(tool_dir_path: str):
@router.get("/download/{tool_name}/{session_uuid}/{filename}")
async def download_file(tool_name: str, session_uuid: str, filename: str):
"""
Безопасная отдача файла с каноническим именем из изолированной директории.
"""
safe_tool = os.path.basename(tool_name)
safe_uuid = os.path.basename(session_uuid)
safe_filename = os.path.basename(filename)
@@ -53,14 +52,16 @@ async def download_file(tool_name: str, session_uuid: str, filename: str):
if not os.path.exists(file_path) or not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
# Определение MIME-типа
media_type = "application/octet-stream"
if safe_filename.endswith((".md", ".txt")):
if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
media_type = "text/markdown; charset=utf-8"
elif safe_filename.endswith(".xlsx"):
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
elif safe_filename.endswith(".pdf"):
media_type = "application/pdf"
# Корректная кодировка для кириллических имен файлов
encoded_filename = urllib.parse.quote(safe_filename)
return FileResponse(
@@ -69,47 +70,4 @@ async def download_file(tool_name: str, session_uuid: str, filename: str):
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
)
def _find_report_recursively(target_filename: str) -> Optional[str]:
"""Рекурсивный поиск файла отчета по имени в /tmp и во всех подпапках output/reports/."""
# 1. Проверяем /tmp/scud_reports
tmp_path = os.path.join(TEMP_REPORTS_DIR, target_filename)
if os.path.exists(tmp_path) and os.path.isfile(tmp_path):
return tmp_path
# 2. Проверяем прямой путь в output/reports
direct_path = os.path.join(REPORTS_DIR, target_filename)
if os.path.exists(direct_path) and os.path.isfile(direct_path):
return direct_path
# 3. Рекурсивный поиск по подкаталогам (год/месяц)
for root, _, files in os.walk(REPORTS_DIR):
if target_filename in files:
return os.path.join(root, target_filename)
return None
@router.get("/download/reports/{filename:path}")
async def download_report_direct(filename: str):
"""
Прямое скачивание отчетов:
- Декодирует UTF-8 URL (%20 -> пробел).
- Ищет файл в output/reports/{YEAR}/{MONTH} и во временном буфере /tmp/scud_reports.
"""
decoded_name = urllib.parse.unquote(filename).strip()
safe_filename = os.path.basename(decoded_name)
target_path = _find_report_recursively(safe_filename)
if not target_path or not os.path.exists(target_path):
raise HTTPException(status_code=404, detail="Отчет не найден")
encoded_filename = urllib.parse.quote(safe_filename)
return FileResponse(
path=target_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
)
@@ -1,88 +0,0 @@
"""
===============================================================================
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
@@ -1,26 +0,0 @@
"""
===============================================================================
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
@@ -1,157 +0,0 @@
"""
===============================================================================
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
@@ -1,128 +0,0 @@
"""
===============================================================================
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
@@ -1,400 +0,0 @@
"""
===============================================================================
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"
)
+7 -8
View File
@@ -3,20 +3,19 @@
FILE: modules/web_api/routers/tasks.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers
ROLE: REST API эндпоинты реестра задач.
ROLE: REST API эндпоинты реестра задач (получение, создание и обновление).
===============================================================================
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
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
from llm.db_tools import db_get_tasks, db_add_task, db_update_task_details
router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
class TaskCreateRequest(BaseModel):
title: str
priority: Optional[str] = "MEDIUM"
@@ -24,7 +23,6 @@ class TaskCreateRequest(BaseModel):
due_date: Optional[str] = None
status: Optional[str] = "BACKLOG"
class TaskUpdateRequest(BaseModel):
title: Optional[str] = None
priority: Optional[str] = None
@@ -33,6 +31,7 @@ class TaskUpdateRequest(BaseModel):
def resolve_user_id(current_user: Dict[str, Any]) -> int:
"""Извлекает корректный ID пользователя из JWT payload или ставит дефолтный 1."""
if not current_user:
return 1
return current_user.get("id") or current_user.get("user_id") or 1
@@ -41,13 +40,13 @@ def resolve_user_id(current_user: Dict[str, Any]) -> int:
@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)}
return {"tasks": db_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(
res = db_add_task(
user_id=user_id,
module=req.module,
title=req.title,
@@ -63,7 +62,7 @@ async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(ge
@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(
res = db_update_task_details(
user_id=user_id,
task_id=task_id,
title=req.title,
+242 -303
View File
@@ -1,325 +1,264 @@
<!DOCTYPE html>
<html lang="ru" class="h-full bg-slate-100">
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SCUD Orion AI Assistant</title>
<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>
<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="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>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body class="h-full flex flex-col font-sans antialiased text-slate-800 bg-slate-100 selection:bg-indigo-500 selection:text-white">
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
<div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">
<!-- ANCHOR[AUTH_MODAL]: Модальное окно входа -->
<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>
<!-- ЛЕВАЯ КОЛОНКА (САЙДБАР) -->
<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 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> Контекст
<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>
<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>
</button>
</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>
<!-- 2. СРЕЗЫ И ОТЧЕТЫ -->
<div id="sidebar-view-snapshots" class="sidebar-view w-full space-y-3 hidden">
<!-- ПЕРИОД ВЫБОРКИ СРЕЗОВ -->
<div class="p-2.5 bg-slate-50 rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-slate-600 uppercase flex items-center gap-1.5">
<i class="fa-regular fa-calendar text-indigo-500"></i> Период срезов:
</span>
<button onclick="loadSnapshotsView()" class="text-xs text-indigo-600 hover:text-indigo-800 font-semibold flex items-center gap-1 transition">
<i class="fa-solid fa-rotate-right text-[10px]"></i> Найти
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-[10px] text-slate-400 block mb-0.5">С даты:</label>
<input type="date" id="snapshots-date-from" onchange="loadSnapshotsView()"
class="w-full text-xs px-2 py-1.5 bg-white border border-slate-200 rounded-lg text-slate-700 font-mono focus:outline-none focus:border-indigo-500 cursor-pointer">
</div>
<div>
<label class="text-[10px] text-slate-400 block mb-0.5">По дату:</label>
<input type="date" id="snapshots-date-to" onchange="loadSnapshotsView()"
class="w-full text-xs px-2 py-1.5 bg-white border border-slate-200 rounded-lg text-slate-700 font-mono focus:outline-none focus:border-indigo-500 cursor-pointer">
</div>
</div>
<div class="flex items-center gap-1.5 pt-1 border-t border-slate-200/60">
<button onclick="setSnapshotDatePreset('today')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
Сегодня
</button>
<button onclick="setSnapshotDatePreset('yesterday')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
Вчера
</button>
<button onclick="setSnapshotDatePreset('days3')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
3 дня
</button>
<button onclick="setSnapshotDatePreset('days7')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
7 дней
</button>
</div>
</div>
<!-- Панель создания срезов -->
<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="text-[11px] font-bold text-slate-600 uppercase flex items-center gap-1.5">
<i class="fa-regular fa-clock text-indigo-500"></i> Создать срез на дату/время:
</div>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="manual-snapshot-date" placeholder="ДД.ММ.ГГГГ" class="text-xs px-2.5 py-1.5 border border-slate-200 rounded-lg text-center font-mono">
<input type="text" id="manual-snapshot-time" placeholder="ЧЧ:ММ" class="text-xs px-2.5 py-1.5 border border-slate-200 rounded-lg text-center font-mono">
</div>
<button id="btn-create-snapshot" onclick="createSnapshotManual()" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold transition flex items-center justify-center gap-1.5">
<i class="fa-solid fa-camera"></i> Сделать срез
</button>
</div>
<!-- Панель On-Demand генерации отчетов -->
<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs space-y-2">
<div class="text-[11px] font-bold text-slate-700 uppercase flex items-center gap-1.5">
<i class="fa-solid fa-file-excel text-emerald-600"></i> Сформировать отчет:
</div>
<div class="grid grid-cols-3 gap-1.5">
<button onclick="generateReportDirect('SVODKA')" class="py-2 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-table-list"></i> Сводка
</button>
<button onclick="generateReportDirect('SIMPLIFIED')" class="py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-file-lines"></i> Упрощ.
</button>
<button onclick="generateReportDirect('DETAILED')" class="py-2 bg-purple-50 hover:bg-purple-100 text-purple-700 border border-purple-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
<i class="fa-solid fa-chart-column"></i> Детальн.
</button>
</div>
</div>
<div class="flex items-center justify-between pt-1">
<span id="snapshots-count-badge" class="text-xs font-bold text-slate-700">Срезы в базе: 0</span>
<button onclick="loadSnapshotsView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить список">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="snapshots-list" class="space-y-2"></div>
</div>
<!-- 3. РЕЕСТРЫ -->
<div id="sidebar-view-registries" class="sidebar-view w-full space-y-3 hidden">
<div class="grid grid-cols-2 gap-1 p-1 bg-slate-200/60 rounded-xl">
<button data-subtab="exceptions" onclick="switchRegistrySubTab('exceptions')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-user-shield text-[10px]"></i> Исключения
</button>
<button data-subtab="remote" onclick="switchRegistrySubTab('remote')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-house-laptop text-[10px]"></i> Удаленщики
</button>
<button data-subtab="local_trip" onclick="switchRegistrySubTab('local_trip')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-location-dot text-[10px]"></i> Мест. командир.
</button>
<button data-subtab="other" onclick="switchRegistrySubTab('other')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
<i class="fa-solid fa-clipboard-question text-[10px]"></i> Иное
</button>
</div>
<div id="registry-content-container" class="space-y-3"></div>
</div>
<!-- 4. ПРОМПТ -->
<div id="sidebar-view-prompts" class="sidebar-view w-full space-y-3 hidden">
<div class="flex items-center justify-between pb-1 border-b border-slate-200">
<span class="text-xs font-bold text-slate-800">Системный промпт (Ollama)</span>
<button onclick="loadPromptsView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="prompts-content-container" class="space-y-2"></div>
</div>
<!-- 5. КОНТЕКСТ -->
<div id="sidebar-view-context" class="sidebar-view w-full space-y-3 hidden">
<div class="flex items-center justify-between pb-1 border-b border-slate-200">
<span class="text-xs font-bold text-slate-800">Мониторинг сессии</span>
<button onclick="loadContextView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div id="context-content-container" class="space-y-2"></div>
</div>
<div 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>
<!-- ПОДВАЛ -->
<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 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>
</div>
<!-- БЕЙДЖ ПРИКРЕПЛЕННОГО ФАЙЛА -->
<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>
<!-- СТРОКА ВВОДА -->
<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>
<!-- ПОЛНОЭКРАННЫЙ ОВЕРЛЕЙ 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>
<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>
</button>
</div>
</div>
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
<div id="modals-container"></div>
<!-- ANCHOR[CHANGE_PWD_MODAL]: Модальное окно смены пароля -->
<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>
<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>
<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>
<!-- ANCHOR[ADMIN_MODAL]: Модальное окно управления пользователями -->
<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>
<!-- ANCHOR[HEADER_BAR]: Хедер интерфейса -->
<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>
<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>
</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>
</button>
<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>
</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>
<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>
</button>
</div>
</header>
<!-- ANCHOR[CHAT_MAIN_CONTAINER]: Главный контейнер чата -->
<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>
</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>
</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>
<i class="fa-solid fa-paper-plane text-xs"></i>
</button>
</div>
</div>
</div>
<!-- ANCHOR[TASK_DRAWER_CONTAINER]: Боковая панель задач -->
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
<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>
<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>
</div>
</div>
<script src="/static/js/presence.js?v=2.6.0"></script>
<!-- ANCHOR[DRAWER_TAB_RENAMING]: Вкладки фильтрации боковой панели -->
<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>
<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>
<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"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/chat/task_widget.js"></script>
<script src="/static/js/chat/core.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+3 -385
View File
@@ -1,11 +1,3 @@
/**
* ===============================================================================
* 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";
@@ -18,379 +10,9 @@ let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
let historyIndex = -1;
// ============================================================================
// 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. Авто-высота поля ввода команд
document.addEventListener("DOMContentLoaded", () => {
const userInputEl = document.getElementById("user-input");
if (userInputEl) {
userInputEl.addEventListener("input", function() {
this.style.height = "24px";
@@ -399,19 +21,15 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
// 5. Проверка сессии пользователя
if (IS_GUEST) {
hideAuthModal();
updateUIState();
} else if (AuthManager && AuthManager.isAuthenticated()) {
} else if (API_TOKEN) {
hideAuthModal();
updateUIState();
if (typeof loadTasks === "function") {
loadTasks();
}
if (window.SidebarManager) {
SidebarManager.init();
}
} else {
showAuthModal();
}
+298 -65
View File
@@ -1,75 +1,308 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/auth.js
* ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков.
* ===============================================================================
*/
function showAuthModal() {
const el = document.getElementById("auth-modal");
if (el) el.classList.remove("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";
function hideAuthModal() {
const el = document.getElementById("auth-modal");
if (el) el.classList.add("hidden");
}
const AuthManager = {
getToken() {
return localStorage.getItem(AUTH_STORAGE_KEY) || "";
},
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");
getUserId() {
const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
return uid ? parseInt(uid, 10) : 1;
},
if (!usernameInput || !passwordInput) return;
getUsername() {
return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
},
const username = usernameInput.value.trim();
const password = passwordInput.value;
getFullName() {
return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
},
if (!username || !password) return;
isAdmin() {
return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
},
if (errorEl) errorEl.classList.add("hidden");
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=/`;
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
window.location.href = "/";
}
};
const data = await res.json();
window.AuthManager = AuthManager;
window.logout = () => AuthManager.logout();
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
},
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
},
body: JSON.stringify({ username, password, full_name, is_admin })
});
const data = await res.json();
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("Ошибка при удалении");
}
}
File diff suppressed because it is too large Load Diff
@@ -1,215 +0,0 @@
/**
* ===============================================================================
* 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
@@ -1,176 +0,0 @@
/**
* ===============================================================================
* 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
@@ -1,70 +0,0 @@
/**
* Роутер табов сайдбара и общие утилиты.
*/
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();
});
@@ -1,100 +0,0 @@
/**
* Модуль вкладок "Промпт" и "Контекст".
*/
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('Ошибка сети'); }
}
@@ -1,420 +0,0 @@
/**
* Модуль вкладок "Реестры": исключения, удаленщики, командировки, флигель.
*/
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('Ошибка сети');
}
}
@@ -1,308 +0,0 @@
/**
* Модуль вкладки "Срезы": список снапшотов по диапазону дат, создание, инспекция и генерация отчетов.
*/
// 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;
@@ -1,138 +0,0 @@
/**
* ===============================================================================
* 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');
};
+142 -188
View File
@@ -1,210 +1,164 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/tasks.js
* ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
* ===============================================================================
*/
===============================================================================
FILE: modules/web_api/static/js/tasks.js
ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек.
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;');
};
AI-CONTEXT-ANCHORS:
- ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели.
- ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED).
- ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks.
- ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню.
===============================================================================
*/
let currentTasksFilter = 'ALL';
let tasksCache = [];
let currentFilter = 'ALL';
let allTasks = [];
function getTasksContainer() {
return document.getElementById("tasks-list-container") ||
document.getElementById("sidebar-dynamic-content") ||
document.getElementById("tasks-list");
// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE]
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;
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");
}
}
async function loadTasks() {
const container = getTasksContainer();
if (!container) 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: AuthManager.getAuthHeaders()
});
if (!res.ok) {
if (res.status === 401) {
showAuthModal();
return;
}
throw new Error(`Ошибка сервера (${res.status})`);
// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER]
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";
}
const data = await res.json();
tasksCache = Array.isArray(data) ? data : (data.tasks || []);
renderTasksUI();
} catch (err) {
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>
`;
}
});
renderTasks();
}
function setTaskFilter(filter) {
currentTasksFilter = filter;
renderTasksUI();
}
// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH]
async function loadTasks() {
const badge = document.getElementById("task-count-badge");
const container = document.getElementById("tasks-container");
function renderTasksUI() {
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");
let filtered = tasksCache;
if (currentTasksFilter === 'IN_PROGRESS') {
filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS');
} else if (currentTasksFilter === 'BACKLOG') {
filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED');
} else if (currentTasksFilter === 'COMPLETED') {
filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE');
}
const filtersHtml = `
<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 = `
${filtersHtml}
${addBtnHtml}
<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">
Нет задач в выбранной категории
</div>
`;
if (isGuest || !token) {
if (badge) badge.innerText = "0";
return;
}
const itemsHtml = filtered.map(t => {
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
const priorityColors = {
'HIGH': 'bg-rose-50 text-rose-700 border-rose-200',
'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200',
'LOW': 'bg-slate-50 text-slate-600 border-slate-200'
};
const pClass = priorityColors[t.priority] || priorityColors['MEDIUM'];
return `
<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="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>
</div>
</div>
`;
}).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"
})
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
}
});
if (res.ok) {
loadTasks();
if (res.status === 401) {
if (typeof logout === 'function') logout();
return;
}
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
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 {
alert("Не удалось создать задачу");
allTasks = [];
}
if (badge) {
badge.innerText = allTasks.length.toString();
}
renderTasks();
} 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>`;
}
} catch (e) {
alert("Ошибка сети");
}
}
window.loadTasks = loadTasks;
window.setTaskFilter = setTaskFilter;
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
function renderTasks() {
const container = document.getElementById("tasks-container");
if (!container) return;
if (!Array.isArray(allTasks)) {
allTasks = [];
}
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
return;
}
container.innerHTML = filtered.map(t => {
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
let statusLabel = "В планах";
let cardBg = "bg-white";
if (t.status === "COMPLETED") {
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
statusLabel = "Завершено";
cardBg = "bg-emerald-50/20";
} else if (t.status === "IN_PROGRESS") {
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
statusLabel = "В работе";
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>` : "";
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>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
</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("");
}
@@ -1,32 +0,0 @@
<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>
@@ -1,28 +0,0 @@
<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>
@@ -1,45 +0,0 @@
<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>
@@ -1,59 +0,0 @@
<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>
@@ -1,101 +0,0 @@
<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>
@@ -1,25 +0,0 @@
<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>
@@ -1,58 +0,0 @@
<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>
@@ -1,94 +0,0 @@
<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
@@ -1,14 +0,0 @@
#!/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
@@ -1,9 +0,0 @@
#!/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
@@ -1,9 +0,0 @@
#!/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
+93 -205
View File
@@ -7,10 +7,7 @@ from datetime import datetime
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from modules.web_api.llm.db.db_chat import db_clear_all_chat_context
from core.database import dump_database_to_excel
from core.repositories.scud_repo import get_building_presence
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR, EXCEPTIONS_PATH, normalize_fio
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR
from core.database import (
get_connection,
get_available_snapshots,
@@ -18,54 +15,9 @@ 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)
@@ -73,29 +25,25 @@ 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
WHERE is_active = 1
ORDER BY id ASC
""")
rows = cursor.fetchall()
if not rows:
print("Таблица tool_action_registry пуста.")
else:
for r in rows:
print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}")
print(f" • Сообщение: {r[4]}")
if r[5]:
print(f" • Вопрос: {r[5]}")
print(f" • Кнопки: {r[6]}")
print("-" * 110)
except Exception as e:
print(f"Таблица tool_action_registry недоступна: {e}")
cursor.execute("""
SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json
FROM tool_action_registry
WHERE is_active = 1
ORDER BY id ASC
""")
rows = cursor.fetchall()
if not rows:
print("Таблица tool_action_registry пуста.")
else:
for r in rows:
print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}")
print(f" • Сообщение: {r[4]}")
if r[5]:
print(f" • Вопрос: {r[5]}")
print(f" • Кнопки: {r[6]}")
print("-" * 110)
print("=" * 110 + "\n")
def print_stats():
"""Выводит общую статистику по записям в таблицах БД."""
print("\n" + "=" * 60)
@@ -103,16 +51,12 @@ def print_stats():
print("=" * 60)
with get_connection() as conn:
cursor = conn.cursor()
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'
]
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']
for t in tables:
try:
cursor.execute(f"SELECT COUNT(*) FROM {t}")
cnt = cursor.fetchone()[0]
print(f" • Таблица [{t:<22}]: {cnt:>6} записей")
print(f" • Таблица [{t:<20}]: {cnt:>6} записей")
except Exception:
pass
print("=" * 60 + "\n")
@@ -138,11 +82,13 @@ 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)
@@ -158,7 +104,11 @@ 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
formatted_snap_id = f" {snap_id}" if not snap_id.startswith("Y") else snap_id
if not snap_id.startswith("Y"):
formatted_snap_id = f" {snap_id}"
else:
formatted_snap_id = snap_id
print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}")
@@ -191,44 +141,31 @@ 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" + "=" * 115)
print("\n" + "=" * 90)
if snapshot_id:
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата среза: {target_date}):")
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):")
else:
print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):")
print("=" * 115)
print("=" * 90)
df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
if df is None or df.empty:
if df.empty:
print("Записи СКУД не найдены.")
print("=" * 115 + "\n")
print("=" * 90 + "\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)
if present_col:
present_cnt = len(df[df[present_col].astype(str).str.lower().isin(['true', '1'])])
else:
present_cnt = 0
present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0
absent_cnt = total - present_cnt
print(f"Всего записей: {total} | Присутствовали: {present_cnt} | Отсутствовали: {absent_cnt}")
print("-" * 115)
print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}")
print("-" * 90)
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))
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]
print(df[existing_cols].head(30).to_string(index=False))
if len(df) > 30:
print(f"\n... и ещё {len(df) - 30} строк.")
@@ -238,11 +175,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" + "*" * 115)
print("\n" + "*" * 90)
print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}")
print("*" * 115)
print("*" * 90)
print("=" * 115 + "\n")
print("=" * 90 + "\n")
def print_absences(date_str=None):
@@ -299,8 +236,15 @@ def print_rules():
def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
"""Дампит всю базу SQLite во многостраничный Excel."""
print(f"\n[🔄] Создание полного дампа БД в файл: {out_filename} ...")
out_path = dump_database_to_excel(out_filename)
out_path = os.path.join(OUTPUT_DIR, out_filename)
print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']:
try:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
except Exception:
pass
print(f"[✓] Дамп успешно сохранен: {out_path}\n")
@@ -389,47 +333,44 @@ def print_chat_messages(session_id=None, limit=50):
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")
"""
Очистка контекста сообщений:
- По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий.
- purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии.
"""
with get_connection() as conn:
cursor = conn.cursor()
if purge_all:
if session_id:
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
else:
cursor.execute("DELETE FROM chat_messages")
cursor.execute("DELETE FROM session_states")
deleted_msgs = cursor.rowcount
conn.commit()
print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n")
return
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}")
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:
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")
cursor.execute(query)
cursor.execute("DELETE FROM session_states")
deleted_msgs = cursor.rowcount
conn.commit()
print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
HELP_TEXT = """
@@ -439,7 +380,6 @@ CLI-утилита инспекции и управления SQLite базой
stats -- Общая статистика строк по всем таблицам БД
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
in_building [ДД.ММ.ГГГГ] [--all] -- Оперативный статус: кто сейчас в здании (или все статусы с флагом --all)
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
@@ -451,17 +391,10 @@ CLI-утилита инспекции и управления SQLite базой
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 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
@@ -472,11 +405,6 @@ CLI-утилита инспекции и управления SQLite базой
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 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
"""
@@ -490,20 +418,9 @@ 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', '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('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools', 'context'], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del', 'purge') или session_id для контекста")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, '--all' или имя файла)")
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="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
@@ -519,13 +436,12 @@ def main():
if args.command == 'stats':
print_stats()
elif args.command == 'snapshots':
date_val = args.action or args.param
date_val = args.param or args.action
print_snapshots_list(date_str=date_val)
elif args.command == 'scud':
date_val = args.action or args.param
inspect_scud(snapshot_id=args.snapshot, date_str=date_val, export_xlsx=args.export_xlsx)
inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx)
elif args.command == 'absences':
date_val = args.action or args.param
date_val = args.param or args.action
print_absences(date_str=date_val)
elif args.command == 'anomalies':
print_anomalies()
@@ -537,9 +453,6 @@ 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')
@@ -549,7 +462,7 @@ def main():
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.action or args.param or "db_dump_full.xlsx"
filename = args.param if args.param else "db_dump_full.xlsx"
dump_all_to_excel(filename)
elif args.command == 'snapshot':
if args.action == 'del':
@@ -563,34 +476,9 @@ 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[❌] Ошибка добавления исключения [{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()
+46
View File
@@ -0,0 +1,46 @@
import os
OUTPUT_SNAPSHOT = "project_code_snapshot.md"
# Расширения файлов для включения в снимок (только исходный код)
ALLOWED_EXTENSIONS = {'.py', '.json', '.sh', '.ini', '.js', '.html', '.css', '.sql'}
# Исключаемые каталоги (убираем docs, кэши, архивы и окружения)
EXCLUDE_DIRS = {
'.git', '__pycache__', 'venv', '.venv', 'output', 'logs',
'extracted_project', 'docs', 'data'
}
# Исключаемые файлы
EXCLUDE_FILES = {
OUTPUT_SNAPSHOT,
'api_code_snapshot.md',
'project_code_snapshot.md',
'scud_orion_ai_v2.tar.gz',
'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_orion_ai\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('.', '') + "\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")
size_kb = os.path.getsize(OUTPUT_SNAPSHOT) / 1024
print(f"✓ Слепок успешно создан: {OUTPUT_SNAPSHOT} ({size_kb:.1f} KB)")
-119
View File
@@ -1,119 +0,0 @@
#!/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
@@ -1,92 +0,0 @@
#!/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()
@@ -1,22 +0,0 @@
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)")
+11 -30
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(real_unrecognized, ensure_ascii=False)}
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
СТРОГИЕ ПРАВИЛА:
@@ -173,59 +173,40 @@ 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 объектом без пояснительного текста."
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
)
mapping = {}
if not raw_response:
return mapping
# Фаза 1: Попытка прямого разбора с санитарной очисткой
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
json_str = match.group(0)
# Убираем висячие запятые: {"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)
json_str = re.sub(r'\}\s*[^}\]]*$', '}', 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 and staff_f in staff_fios_set:
if scud_f and staff_f:
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"[!] Ошибка резервного парсинга опечаток: {e}")
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
return mapping
+169 -150
View File
@@ -1,18 +1,11 @@
"""
===============================================================================
FILE: services/data_loader.py
ROLE: Надежная загрузка штата и отсутствий (MS SQL ЗУП -> Резервный Excel).
===============================================================================
"""
import os
import warnings
from datetime import datetime # <-- ДОБАВИТЬ ЭТУ СТРОКУ
import pandas as pd
import warnings
from config import (
normalize_fio, clean_scud_fio_light, load_exceptions,
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR, DATA_DIR
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
)
# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite
from services.zup_extractor import fetch_zup_absences_from_sql
from core.database import (
load_scud_from_db_by_snapshot,
@@ -23,8 +16,23 @@ 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С."""
"""
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
"""
filepath = find_dated_file("Штат", date_str)
if not filepath:
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
@@ -35,6 +43,8 @@ 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 = ['ФИО', 'Подразделение', 'Должность']
@@ -47,168 +57,58 @@ 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С:ЗУП, а при сбое — из локального Excel.
Загружает документально подтвержденные отсутствия сотрудников
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату,
а также автоматически обогащает их удаленщиками из static_reason_workers.csv.
"""
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" [⚠️] Ошибка подключения к 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:
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
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, dtype=str, on_bad_lines='skip').fillna("")
for col in ['fio', 'reason', 'department', 'date_from', 'date_to']:
if col not in df_static.columns:
df_static[col] = ""
today_date = datetime.now().date()
# Разбор целевой даты отчета
clean_target_str = str(date_str).replace('_', '.')
try:
clean_target_date = datetime.strptime(clean_target_str, "%d.%m.%Y").date()
except ValueError:
clean_target_date = today_date
def parse_date_safe(d_val):
if not d_val or str(d_val).lower() in ['nan', 'none', '', 'nat']:
return None
s = str(d_val).strip().replace('_', '.')
for fmt in ("%d.%m.%Y", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
pass
return None
active_for_file = []
new_rows_for_report = []
file_changed = False
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
for _, s_row in df_static.iterrows():
fio_raw = str(s_row.get('fio', '')).strip()
if not fio_raw:
continue
fio_c = normalize_fio(fio_raw)
reason = str(s_row.get('reason', '')).strip() or "Дистанционная работа"
d_from = parse_date_safe(s_row.get('date_from'))
d_to = parse_date_safe(s_row.get('date_to'))
# 1. Физическая ротация просроченных: только если текущий реальный день (today) строго больше date_to
if d_to is not None and today_date > d_to:
print(f" [🧹] Удаленка истекла: {fio_raw} (до {d_to.strftime('%d.%m.%Y')}). Удалена из CSV.")
file_changed = True
continue
active_for_file.append(s_row.to_dict())
# 2. Проверка действия удаленки на дату формируемого отчета:
# Если date_from не указана — действует всегда до date_to
is_after_start = (d_from is None) or (clean_target_date >= d_from)
is_before_end = (d_to is None) or (clean_target_date <= d_to)
if is_after_start and is_before_end:
if fio_c not in existing_fios:
new_rows_for_report.append({
'fio_clean': fio_c,
'Вид_отсутствия': reason
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
new_rows = []
for _, s_row in df_static.iterrows():
if s_row['fio_clean'] not in existing_fios:
new_rows.append({
'fio_clean': s_row['fio_clean'],
'Вид_отсутствия': s_row['reason']
})
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}")
if new_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
except Exception as e:
print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}")
# Обогащение реестрами "Мест. командир." и "Иное"
try:
from services.manual_absences_repo import get_active_manual_absences_for_date
manual_records = get_active_manual_absences_for_date(date_str)
if manual_records:
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
manual_rows = []
for r in manual_records:
fc = r['fio_clean']
if fc not in existing_fios:
label = "Мест. командир." if r['absence_type'] == 'LOCAL_TRIP' else "Иное"
manual_rows.append({
'fio_clean': fc,
'Вид_отсутствия': label
})
existing_fios.add(fc)
if manual_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(manual_rows)], ignore_index=True)
print(f" [✓] Реестры 'Мест. командир.' / 'Иное': добавлено {len(manual_rows)} чел. в отчет за {date_str}")
except Exception as e:
print(f" [⚠️] Ошибка применения manual_absences: {e}")
print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {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
@@ -216,9 +116,128 @@ 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
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
)
+399 -17
View File
@@ -1,19 +1,401 @@
"""
===============================================================================
FILE: services/excel_exporter.py
ROLE: Единый фасад генераторов Excel-отчетов (обратная совместимость).
===============================================================================
"""
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
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
# Словарь месяцев для текстового формата в названиях файлов
MONTHS_RU = {
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
5: "мая", 6: "июня", 7: "июля", 8: "августа",
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
}
# Реэкспорт функций генерации
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
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)
-94
View File
@@ -1,94 +0,0 @@
"""
===============================================================================
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
@@ -1,37 +0,0 @@
"""
===============================================================================
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
@@ -1,222 +0,0 @@
"""
===============================================================================
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
@@ -1,89 +0,0 @@
"""
===============================================================================
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
@@ -1,55 +0,0 @@
"""
===============================================================================
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
@@ -1,63 +0,0 @@
"""
===============================================================================
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
@@ -1,256 +0,0 @@
"""
===============================================================================
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
@@ -1,79 +0,0 @@
"""
===============================================================================
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
@@ -1,114 +0,0 @@
"""
===============================================================================
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
@@ -1,49 +0,0 @@
"""
===============================================================================
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
@@ -1,56 +0,0 @@
"""
===============================================================================
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
@@ -1,119 +0,0 @@
"""
===============================================================================
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
@@ -1,42 +0,0 @@
"""
===============================================================================
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
@@ -1,147 +0,0 @@
"""
===============================================================================
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
-80
View File
@@ -1,80 +0,0 @@
"""
===============================================================================
FILE: services/reports/styles.py
ROLE: Стили, палитры цветов, форматирование дат и защита от блокировок Excel.
===============================================================================
"""
import os
import time
from datetime import datetime
from xlsxwriter.exceptions import FileCreateError
from config import REPORTS_DIR
MONTHS_RU_GENITIVE = {
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
5: "мая", 6: "июня", 7: "июля", 8: "августа",
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
}
MONTHS_RU_NOMINATIVE = {
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
5: "май", 6: "июнь", 7: "июль", 8: "август",
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь"
}
def format_date_ru(date_str):
date_clean = str(date_str).replace('_', '.')
try:
dt = datetime.strptime(date_clean, "%d.%m.%Y")
return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}"
except Exception:
return date_str
def get_dated_reports_dir(date_str):
date_clean = str(date_str).replace('_', '.')
try:
dt = datetime.strptime(date_clean, "%d.%m.%Y")
year_str = str(dt.year)
month_name = MONTHS_RU_NOMINATIVE[dt.month]
except Exception:
now = datetime.now()
year_str = str(now.year)
month_name = MONTHS_RU_NOMINATIVE[now.month]
target_dir = os.path.join(REPORTS_DIR, year_str, month_name)
os.makedirs(target_dir, exist_ok=True)
return target_dir
def safe_close_workbook(wb, output_path, target_dir, filename):
try:
wb.close()
return output_path
except (FileCreateError, OSError, PermissionError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename)
try:
wb.filename = alt_path
wb._store_workbook()
return alt_path
except Exception:
return output_path
def create_xlsx_format(workbook, font_name="Calibri", font_size=11, bg_color=None, bold=False, align="left", wrap=False):
fmt_dict = {
'font_name': font_name,
'font_size': font_size,
'bold': bold,
'align': align,
'valign': 'vcenter',
'border': 1,
'border_color': '#D3D3D3',
'text_wrap': wrap
}
if bg_color:
fmt_dict['bg_color'] = bg_color
return workbook.add_format(fmt_dict)
-219
View File
@@ -1,219 +0,0 @@
"""
===============================================================================
FILE: services/reports/svodka_builder.py
ROLE: Генератор книги Ежедневной сводки (иерархические группировки XlsxWriter).
===============================================================================
"""
import os
import math
import pandas as pd
import xlsxwriter
from services.reports.styles import get_dated_reports_dir, format_date_ru, safe_close_workbook, create_xlsx_format
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
date_clean = str(date_str).replace('_', '.')
if not filename:
filename = f"{format_date_ru(date_clean)} сводка.xlsx"
target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename)
wb = xlsxwriter.Workbook(output_path, {'nan_inf_to_errors': True})
ws = wb.add_worksheet("Лист_1")
ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False)
fmt_hdr_l = create_xlsx_format(wb, bg_color='#D9E1F2', bold=True, align="left")
fmt_hdr_r = create_xlsx_format(wb, bg_color='#D9E1F2', bold=True, align="right")
fmt_tot_l = create_xlsx_format(wb, bg_color='#F2F2F2', bold=True, align="left")
fmt_tot_r = create_xlsx_format(wb, bg_color='#F2F2F2', bold=True, align="right")
fmt_empty = create_xlsx_format(wb)
ws.set_row(0, 20)
ws.write(0, 0, "Сводка на", fmt_hdr_l)
ws.write(0, 1, date_clean, fmt_hdr_r)
ws.set_row(1, 20)
ws.write(1, 0, "", fmt_empty)
ws.write(1, 1, "", fmt_empty)
is_not_hired = merged_df.get('not_hired_yet', False) == True
is_no_pass = merged_df.get('no_scud_pass', False) == True
is_exc = merged_df.get('is_excluded', False) == True
# "По списку" строго по официальному штату 1С
staff_total = len(merged_df[~is_not_hired])
ws.set_row(2, 20)
ws.write(2, 0, "По списку", fmt_tot_l)
ws.write(2, 1, staff_total, fmt_tot_r)
current_row = 3
# 1. Неизвестно
unexplained = merged_df[
(~is_not_hired) &
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'None']))) &
(~is_no_pass) & (~is_exc)
]
fmt_unexp_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left")
fmt_unexp_hr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="right")
fmt_unexp_rl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left")
fmt_unexp_rr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "неизвестно", fmt_unexp_hl)
ws.write(current_row, 1, len(unexplained), fmt_unexp_hr)
current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
ws.write(current_row, 0, str(fio), fmt_unexp_rl)
ws.write(current_row, 1, "", fmt_unexp_rr)
current_row += 1
# 2. Нет пропуска (в штате 1С есть, но карты СКУД нет)
no_pass_df = merged_df[is_no_pass & (~is_exc) & (~is_not_hired)]
fmt_np_hl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="left")
fmt_np_hr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="right")
fmt_np_rl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="left")
fmt_np_rr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Нет в СКУД", fmt_np_hl)
ws.write(current_row, 1, len(no_pass_df), fmt_np_hr)
current_row += 1
if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
ws.write(current_row, 0, str(fio), fmt_np_rl)
ws.write(current_row, 1, "", fmt_np_rr)
current_row += 1
# 3. Не приняты на работу (в СКУД есть, но в 1С приказа еще нет)
not_hired_df = merged_df[is_not_hired & (~is_exc)]
fmt_nh_hl = create_xlsx_format(wb, bg_color='#FFF0F5', bold=True, align="left")
fmt_nh_hr = create_xlsx_format(wb, bg_color='#FFF0F5', bold=True, align="right")
fmt_nh_rl = create_xlsx_format(wb, bg_color='#FFF0F5', bold=False, align="left")
fmt_nh_rr = create_xlsx_format(wb, bg_color='#FFF0F5', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Нет в ЗУП", fmt_nh_hl)
ws.write(current_row, 1, len(not_hired_df), fmt_nh_hr)
current_row += 1
if not not_hired_df.empty:
for fio in sorted(not_hired_df['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
ws.write(current_row, 0, str(fio), fmt_nh_rl)
ws.write(current_row, 1, "", fmt_nh_rr)
current_row += 1
# 4. Официальные отсутствия
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
absent_only = merged_df[
(~is_not_hired) &
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason)
]
absent_groups = absent_only.groupby('Вид_отсутствия')
pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF']
for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_c = pastels[idx_cat % len(pastels)]
fmt_cat_hl = create_xlsx_format(wb, bg_color=hex_c, bold=True, align="left")
fmt_cat_hr = create_xlsx_format(wb, bg_color=hex_c, bold=True, align="right")
fmt_cat_rl = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="left")
fmt_cat_rr = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, str(cat_name), fmt_cat_hl)
ws.write(current_row, 1, len(group), fmt_cat_hr)
current_row += 1
is_other_category = (str(cat_name).strip().lower() == "иное")
for _, row in group.sort_values(by='Сотрудник').iterrows():
fio = row.get('Сотрудник', '')
detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else ""
if pd.isna(detail_val):
detail_val = ""
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, str(fio), fmt_cat_rl)
ws.write(current_row, 1, str(detail_val), fmt_cat_rr)
current_row += 1
# 5. Итого на работе (только принятые)
exc_without_doc = merged_df[(~is_not_hired) & is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))]
present_scud = merged_df[(~is_not_hired) & (merged_df['Пришел'] == True) & (~is_exc)]
total_present_count = len(present_scud) + len(exc_without_doc)
fmt_pres_hl = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="left")
fmt_pres_hr = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Итого на работе", fmt_pres_hl)
ws.write(current_row, 1, total_present_count, fmt_pres_hr)
current_row += 1
# 6. Удаленная работа
remote_home = merged_df[(~is_not_hired) & (merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
fmt_rem_hl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="left")
fmt_rem_hr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="right")
fmt_rem_rl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="left")
fmt_rem_rr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl)
ws.write(current_row, 1, len(remote_home), fmt_rem_hr)
current_row += 1
if not remote_home.empty:
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, str(fio), fmt_rem_rl)
ws.write(current_row, 1, "", fmt_rem_rr)
current_row += 1
# 7. Аномалии СКУД и 1С
anomalies = merged_df[
(~is_not_hired) & (~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason) &
(~reason_clean.str.contains('командировк', na=False))) |
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
)
]
fmt_anom_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left")
fmt_anom_hr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="right")
fmt_anom_rl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left")
fmt_anom_rr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left", wrap=True)
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl)
ws.write(current_row, 1, len(anomalies), fmt_anom_hr)
current_row += 1
chars_per_line_b = 30
if not anomalies.empty:
for _, row in anomalies.iterrows():
fio = row.get('Сотрудник', '')
anom_flag = row.get('anomaly_flag', 'NONE')
reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {row.get('Первая_активность', '—')})" if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY' else f"В 1С: {row.get('Вид_отсутствия', '')}"
lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
ws.set_row(current_row, max(lines_count * 18, 20), None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, str(fio), fmt_anom_rl)
ws.write(current_row, 1, str(reason_text), fmt_anom_rr)
current_row += 1
ws.set_column(0, 0, 45)
ws.set_column(1, 1, 38)
safe_close_workbook(wb, output_path, target_dir, filename)
View File
-71
View File
@@ -1,71 +0,0 @@
"""
===============================================================================
FILE: services/scud_etl/anomaly_detector.py
ROLE: Детектирование аномалий СКУД, дубликатов пропусков и несоответствий с 1С.
===============================================================================
"""
import logging
from typing import List, Dict, Any
import pandas as pd
from config import normalize_fio
logger = logging.getLogger("SCUD_ANOMALY")
def detect_registry_anomalies(df_merged: pd.DataFrame, df_raw_scud: pd.DataFrame = None) -> List[Dict[str, Any]]:
anomalies = []
if df_merged is None or df_merged.empty:
return anomalies
# 1. ⭐️ Поиск дубликатов учеток/пропусков в сыром СКУД
if df_raw_scud is not None and not df_raw_scud.empty:
df_raw = df_raw_scud.copy()
if 'fio_clean' not in df_raw.columns:
f_col = 'Сотрудник' if 'Сотрудник' in df_raw.columns else 'ФИО'
df_raw['fio_clean'] = df_raw[f_col].apply(normalize_fio)
counts = df_raw['fio_clean'].value_counts()
duplicate_fios = counts[counts > 1].index.tolist()
for dup_fio in duplicate_fios:
sub = df_raw[df_raw['fio_clean'] == dup_fio]
departments = ", ".join(sub['Подразделение'].astype(str).unique())
anomalies.append({
"fio": dup_fio,
"type": "DUPLICATE_SCUD_CARD",
"description": f"Сотрудник заведен в СКУД {len(sub)} раза (отделы: {departments}). События входа и выхода объединены автоматически."
})
# 2. Поиск кадровых аномалий и сбоев оборудования
for _, row in df_merged.iterrows():
if row.get("is_excluded", False):
continue
fio = row.get("fio_clean") or row.get("Сотрудник", "")
start_day = str(row.get("Начало_дня", "")).strip()
end_day = str(row.get("Конец_дня", "")).strip()
reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip()
reason_lower = reason.lower()
if "исключен" in reason_lower or "овк" in reason_lower:
continue
# Приход в офис во время отпуска/больничного (удаленка и командировки разрешены)
if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan":
if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
anomalies.append({
"fio": fio,
"type": "PHYSICAL_PRESENCE_DURING_ABSENCE",
"description": f"Присутствовал в здании ({start_day}), но в 1С числится документ: '{reason}'."
})
# Ошибка считывателя (есть выход без входа)
if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]:
anomalies.append({
"fio": fio,
"type": "SCUD_EQUIPMENT_ANOMALY",
"description": f"Зафиксирован выход ({end_day}) при отсутствии отметки утреннего входа."
})
return anomalies
-364
View File
@@ -1,364 +0,0 @@
"""
===============================================================================
FILE: services/scud_etl/merger.py
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки.
Распределение исключений в общий рабочий пул при отсутствии справок.
===============================================================================
"""
import logging
from typing import Dict, Any, List
import pandas as pd
from services.knowledge.service import get_department_synonyms_dict
from config import normalize_fio, load_exceptions
from core.connection import get_connection
logger = logging.getLogger("SCUD_MERGER")
def load_identity_mappings() -> Dict[str, str]:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI',
status TEXT DEFAULT 'ACTIVE',
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
cursor.execute("""
SELECT scud_fio, zup_fio
FROM person_identity_mapping
WHERE status = 'ACTIVE'
""")
return {r[0]: r[1] for r in cursor.fetchall()}
def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
if df_scud is None or df_scud.empty:
return df_scud
df = df_scud.copy()
if 'fio_clean' not in df.columns:
fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО'
df['fio_clean'] = df[fio_col].apply(normalize_fio)
mapping_dict = load_identity_mappings()
if mapping_dict:
df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f))
aggregated_rows = []
for fio_clean, group in df.groupby('fio_clean'):
if len(group) == 1:
aggregated_rows.append(group.iloc[0].to_dict())
continue
base_row = group.sort_values(by='Пришел', ascending=False).iloc[0].to_dict()
valid_ins = [
str(t).strip() for t in group['Начало_дня']
if str(t).strip() not in ['Нет входа', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Начало_дня'] = min(valid_ins) if valid_ins else 'Нет входа'
valid_outs = [
str(t).strip() for t in group['Конец_дня']
if str(t).strip() not in ['Нет выхода', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Конец_дня'] = max(valid_outs) if valid_outs else 'Нет выхода'
valid_acts = [
str(t).strip() for t in group['Первая_активность']
if str(t).strip() not in ['—', '', 'nan', 'None', '00:00:00']
]
base_row['Первая_активность'] = min(valid_acts) if valid_acts else '—'
base_row['Пришел'] = any(group['Пришел'] == True) or (base_row['Начало_дня'] != 'Нет входа')
durations = [str(d) for d in group['Находился_в_здании'] if str(d) not in ['00:00', '', 'nan']]
if durations:
base_row['Находился_в_здании'] = max(durations)
aggregated_rows.append(base_row)
return pd.DataFrame(aggregated_rows)
def select_best_zup_position(df_staff_1c: pd.DataFrame, df_scud_agg: pd.DataFrame) -> pd.DataFrame:
if df_staff_1c is None or df_staff_1c.empty:
return pd.DataFrame()
df_staff = df_staff_1c.copy()
if 'fio_clean' not in df_staff.columns:
f_col = 'ФИО' if 'ФИО' in df_staff.columns else 'Сотрудник'
df_staff['fio_clean'] = df_staff[f_col].apply(normalize_fio)
scud_dept_map = {}
if df_scud_agg is not None and not df_scud_agg.empty:
for _, r in df_scud_agg.iterrows():
scud_dept_map[r.get('fio_clean', '')] = str(r.get('Подразделение', '')).strip().upper()
best_rows = []
for fio, group in df_staff.groupby('fio_clean'):
if len(group) == 1:
best_rows.append(group.iloc[0].to_dict())
continue
target_scud_dept = scud_dept_map.get(fio, "")
matched_row = None
if target_scud_dept:
for _, r in group.iterrows():
dept_1c = str(r.get('Подразделение', '')).strip().upper()
if dept_1c == target_scud_dept or target_scud_dept in dept_1c or dept_1c in target_scud_dept:
matched_row = r.to_dict()
break
if not matched_row:
matched_row = group.iloc[0].to_dict()
best_rows.append(matched_row)
return pd.DataFrame(best_rows)
def merge_scud_and_1c(
df_scud: pd.DataFrame,
df_staff_1c: pd.DataFrame,
df_absences_1c: pd.DataFrame
) -> pd.DataFrame:
if (df_scud is None or df_scud.empty) and (df_staff_1c is None or df_staff_1c.empty):
return pd.DataFrame(columns=[
'Сотрудник', 'fio_clean', 'Подразделение', 'Должность',
'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании',
'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия',
'is_excluded', 'not_hired_yet', 'no_scud_pass'
])
synonyms = get_department_synonyms_dict()
exceptions_cfg = load_exceptions()
df_scud_agg = aggregate_scud_by_person(df_scud)
df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg)
staff_fios = set(df_staff_agg['fio_clean'].dropna().tolist()) if df_staff_agg is not None and not df_staff_agg.empty else set()
scud_dict = df_scud_agg.set_index('fio_clean').to_dict('index') if df_scud_agg is not None and not df_scud_agg.empty else {}
merged_rows = []
# 1. Формируем строки по официальному штату 1С
if df_staff_agg is not None and not df_staff_agg.empty:
for _, s_row in df_staff_agg.iterrows():
fio_c = s_row.get('fio_clean', '')
r = dict(s_row)
if 'Сотрудник' not in r or pd.isna(r['Сотрудник']) or str(r['Сотрудник']).strip() == '':
r['Сотрудник'] = r.get('ФИО', fio_c)
if fio_c in scud_dict:
# Сотрудник есть в СКУД — берем короткую аббревиатуру отдела из СКУД
scud_data = scud_dict[fio_c]
dept_scud = str(scud_data.get('Подразделение', '')).strip()
if dept_scud and dept_scud.lower() not in ['nan', 'none', '—', 'без подразделения']:
r['Подразделение'] = dept_scud
r['Начало_дня'] = scud_data.get('Начало_дня', 'Нет входа')
r['Первая_активность'] = scud_data.get('Первая_активность', '—')
r['Конец_дня'] = scud_data.get('Конец_дня', 'Нет выхода')
r['Находился_в_здании'] = scud_data.get('Находился_в_здании', '00:00')
r['Пришел'] = bool(scud_data.get('Пришел', False))
r['anomaly_flag'] = scud_data.get('anomaly_flag', 'NONE')
r['no_scud_pass'] = False
r['not_hired_yet'] = False
else:
# Сотрудника нет в СКУД (Нет пропуска)
r['Начало_дня'] = 'Нет входа'
r['Первая_активность'] = '—'
r['Конец_дня'] = 'Нет выхода'
r['Находился_в_здании'] = '00:00'
r['Пришел'] = False
r['anomaly_flag'] = 'NONE'
r['no_scud_pass'] = True
r['not_hired_yet'] = False
merged_rows.append(r)
# 2. Сотрудники из СКУД, которых еще нет в 1С (Не приняты на работу)
if df_scud_agg is not None and not df_scud_agg.empty:
for _, scud_row in df_scud_agg.iterrows():
fio_c = scud_row.get('fio_clean', '')
if fio_c not in staff_fios:
r = dict(scud_row)
r['not_hired_yet'] = True
r['no_scud_pass'] = False
if 'Должность' not in r or pd.isna(r['Должность']):
r['Должность'] = '—'
merged_rows.append(r)
df_res = pd.DataFrame(merged_rows)
if "Сотрудник" not in df_res.columns and "ФИО" in df_res.columns:
df_res["Сотрудник"] = df_res["ФИО"]
for col, default_val in [
('Начало_дня', 'Нет входа'),
('Первая_активность', '—'),
('Конец_дня', 'Нет выхода'),
('Находился_в_здании', '00:00'),
('Пришел', False),
('anomaly_flag', 'NONE'),
('not_hired_yet', False),
('no_scud_pass', False)
]:
if col not in df_res.columns:
df_res[col] = default_val
# Словарь синонимов и принудительное сокращение длинных отделов 1С до аббревиатур
reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()}
direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()}
all_dept_map = {
**reverse_synonyms,
**direct_synonyms,
"отдел внутреннего контроля": "ОВК",
"отдел вневедомственного контроля": "ОВК",
"отдел авторского надзора и технического аудита": "ОАН",
"отдел инженерных изысканий": "ОИЗ",
"правовое управление": "ПУ",
"макетная мастерская": "ММ",
"отдел автоматизации": "ОА",
"испытательная геотехническая лаборатория лабораторного центра": "ИГТЛЛ",
"отдел экономики, смет и организации строительства": "ОЭС",
"отдел электротехники, связи и пожарной автоматики": "ОЭСС",
"бетонная лаборатория": "БЛ",
"управление главных инженеров проектов №1": "УГИП №1",
"управление главных инженеров проектов №2": "УГИП №2",
"строительный отдел": "СО",
"гидротехническая экспедиция": "ГЭ",
"планово-экономический отдел": "ПЭО",
"конструкторский отдел": "КО",
"отдел тепловодоснабжения и канализации": "ОТВК",
"электротехнический отдел": "ЭТО"
}
if "Подразделение" in df_res.columns:
df_res["Подразделение"] = df_res["Подразделение"].apply(
lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) if pd.notna(d) else "—"
)
# Привязка кадровых документов 1С
absences_map = {}
if df_absences_1c is not None and not df_absences_1c.empty:
fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None)
reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None)
if fio_col and reason_col:
for _, row in df_absences_1c.iterrows():
fio = normalize_fio(str(row[fio_col]))
reason = str(row[reason_col]).strip()
if reason and reason.lower() not in ["nan", "none"]:
absences_map[fio] = reason
manual_reasons_map = {}
try:
from services.manual_absences_repo import get_active_manual_absences_for_date
target_date_val = df_res.get('Дата', pd.Series()).iloc[0] if 'Дата' in df_res.columns and not df_res.empty else None
if target_date_val:
m_records = get_active_manual_absences_for_date(str(target_date_val))
for mr in m_records:
manual_reasons_map[mr['fio_clean']] = mr['reason']
except Exception:
pass
df_res["detailed_reason"] = df_res["fio_clean"].map(manual_reasons_map).fillna("")
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
# Исключения и белый список
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d]
exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
whitelist_fios = [normalize_fio(f) for f in exceptions_cfg.get("include_fio", []) if f]
df_res["is_excluded"] = False
for idx, row in df_res.iterrows():
fio = row.get("fio_clean", "")
has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() not in ["", "nan", "None", "Исключение"]
if fio in whitelist_fios:
df_res.at[idx, "is_excluded"] = False
continue
dep = str(row.get("Подразделение", "")).upper()
pos = str(row.get("Должность", "")).lower()
is_match_exc = (fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw))
if is_match_exc:
df_res.at[idx, "is_excluded"] = not has_official_absence
mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == ""))
df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение"
df_res.loc[mask_exc, "причина отсутствия"] = "Исключение"
return df_res
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
# Создаем независимую копию среза штата с собственным непрерывным индексом
df_staff_only = df_merged[~df_merged.get('not_hired_yet', False)].copy().reset_index(drop=True)
total_staff = len(df_staff_only)
is_exc_staff = df_staff_only.get('is_excluded', False) == True
is_no_pass_staff = df_staff_only.get('no_scud_pass', False) == True
came_to_office_mask = (df_staff_only["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (~is_exc_staff)
exc_without_doc_mask = (is_exc_staff) & (
df_staff_only["Вид_отсутствия"].isna() |
df_staff_only["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"])
)
working_in_office_count = int((came_to_office_mask | exc_without_doc_mask).sum())
df_not_working = df_staff_only[~came_to_office_mask & ~exc_without_doc_mask].copy().reset_index(drop=True)
reason_series = df_not_working["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home_count = int(is_remote_mask.sum())
df_remaining_absent = df_not_working[~is_remote_mask].copy().reset_index(drop=True)
has_doc_mask = (
df_remaining_absent["причина отсутствия"].notna() &
df_remaining_absent["причина отсутствия"].ne("") &
df_remaining_absent["причина отсутствия"].ne("nan") &
(~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение"))
)
official_absent_count = int(has_doc_mask.sum())
# Неизвестно: среди тех, у кого нет официального документа и кто имеет пропуск
is_no_pass_remaining = df_remaining_absent.get('no_scud_pass', False) == True
unknown = df_remaining_absent[~has_doc_mask & ~is_no_pass_remaining]
unknown_count = len(unknown)
no_pass_count = int((is_no_pass_staff & ~is_exc_staff).sum())
is_not_hired_all = df_merged.get('not_hired_yet', False) == True
is_exc_all = df_merged.get('is_excluded', False) == True
not_hired_count = int((is_not_hired_all & ~is_exc_all).sum())
return {
"total_staff": total_staff,
"working_in_office_count": working_in_office_count,
"remote_home_count": remote_home_count,
"official_absent_count": official_absent_count,
"no_pass_count": no_pass_count,
"not_hired_count": not_hired_count,
"unknown_count": unknown_count,
"unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
}
-59
View File
@@ -1,59 +0,0 @@
"""
===============================================================================
FILE: services/scud_etl/otchet_generator.py
ROLE: Генератор Детального Отчета за прошлые смены (строго по итоговому Y-снапшоту).
===============================================================================
"""
import os
import logging
from typing import Dict, Any, Optional
import pandas as pd
from config import DATE_YESTERDAY
from core.database import load_scud_from_db_by_snapshot
from services.scud_etl.pipeline import load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c
from services.excel_exporter import generate_detailed_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("OTCHET_GENERATOR")
def generate_otchet_service(
target_date: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует детальный отчет за прошедшую смену:
- target_date: дата отчета (по умолчанию вчерашний рабочий день).
- snapshot_id: опциональный ID (по умолчанию выбирается итоговый вечерний срез Y).
"""
date_clean = str(target_date or DATE_YESTERDAY).replace('_', '.')
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Итоговый срез СКУД (Y) за {date_clean} не найден в базе данных."
}
df_staff, df_abs = load_1c_files_for_date(date_clean)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
filename = f"{format_date_ru(date_clean)} отчет.xlsx"
generate_detailed_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "OTCHET",
"date": date_clean,
"snapshot_id": snapshot_id or "AUTO_Y_FINAL",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"total_rows": len(df_merged),
"message": f"Детальный отчет за {date_clean} успешно сформирован."
}
-99
View File
@@ -1,99 +0,0 @@
"""
===============================================================================
FILE: services/scud_etl/pipeline.py
ROLE: Загрузка наилучших срезов СКУД и штата/отсутствий 1С с умным fallback-ом.
===============================================================================
"""
import os
import logging
from typing import Optional, Dict, Any, Tuple
import pandas as pd
from core.connection import get_connection
from core.database import load_scud_from_db_by_snapshot
from config import DATA_DIR
from services.data_loader import load_1c_data_smart, load_staff_data, load_absent_data
logger = logging.getLogger("SCUD_PIPELINE")
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) -> Optional[pd.DataFrame]:
"""
Загружает наилучший срез СКУД за дату.
Если prefer_final_y=True — отдает предпочтение финишному Y (23:59:59).
"""
with get_connection() as conn:
cursor = conn.cursor()
target_snap_id = None
if prefer_final_y:
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00')
ORDER BY id DESC LIMIT 1
""", (date_str,))
row = cursor.fetchone()
if row:
target_snap_id = row[0]
if not target_snap_id:
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
ORDER BY id DESC LIMIT 1
""", (date_str,))
row = cursor.fetchone()
if row:
target_snap_id = row[0]
if not target_snap_id:
return None
return load_scud_from_db_by_snapshot(date_str, snapshot_param=target_snap_id)
def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
"""
Загружает штат и отсутствия с каскадным fallback:
1. Синхронизирует свежие файлы с сетевой шары.
2. Загружает штат и отсутствия через load_1c_data_smart(..., use_db=True).
3. Если штат пуст — берет последний доступный срез штата из zup_staff в SQLite.
"""
clean_date = date_str.replace('_', '.')
try:
from services.share_copier import copy_1c_files_from_share
copy_1c_files_from_share()
except Exception as e:
logger.warning(f"[Pipeline] Ошибка копирования с шары: {e}")
df_staff, df_abs = load_1c_data_smart(clean_date, use_db=True)
# Fallback за штат: ако данашњи штат још увек није доступан, користи се претходни из базе
if df_staff is None or df_staff.empty:
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_date
FROM zup_staff
ORDER BY
SUBSTR(snapshot_date, 7, 4) DESC,
SUBSTR(snapshot_date, 4, 2) DESC,
SUBSTR(snapshot_date, 1, 2) DESC,
id DESC
LIMIT 1
""")
row = cursor.fetchone()
if row and row[0]:
fallback_date = row[0]
df_staff = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(fallback_date,)
)
logger.info(f"[Pipeline] Для даты {clean_date} применен штат за {fallback_date} ({len(df_staff)} чел.)")
return df_staff, df_abs
-186
View File
@@ -1,186 +0,0 @@
"""
===============================================================================
FILE: services/scud_etl/sql_queries.py
ROLE: Изолированные SQL-шаблоны для MS SQL Server (СКУД Орион Pro).
===============================================================================
"""
# 1. Основной срез СКУД с учетом турникетов и флигеля
SQL_QUERY_TEMPLATE = r"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @TargetDate DATE = @InputDate;
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
DECLARE @EndDate DATETIME = {end_datetime_sql};
WITH PercoPassages AS (
-- Физические факты прохода (Event = 32)
SELECT
log.HozOrgan AS EmployeeID,
log.TimeVal,
CASE
WHEN log.Mode = 1 THEN 'IN'
WHEN log.Mode = 2 THEN 'OUT'
ELSE 'OTHER'
END AS Direction
FROM pLogData log WITH (NOLOCK)
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event = 32
AND log.Mode IN (1, 2)
AND (
-- Контур 1: Левый турникет открыт для всех
log.DoorIndex = 1
OR
-- Контур 2: Правый турникет разрешен только для реестра двора
(
log.DoorIndex = 2
AND ({turnstile_filter_sql})
)
OR
-- Контур 3: Флигель 1 эт. разрешен только для реестра флигеля
(
log.DoorIndex = 23
AND ({fligel_filter_sql})
)
)
),
Passages AS (
SELECT
EmployeeID,
MIN(TimeVal) AS FirstRawEvent,
MAX(TimeVal) AS LastRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS FinalOut
FROM PercoPassages
GROUP BY EmployeeID
),
EvaluatedPassages AS (
SELECT
p.*,
CASE
WHEN p.FinalOut IS NOT NULL
AND p.FirstIn IS NOT NULL
AND p.FinalOut > DATEADD(MINUTE, 5, p.FirstIn)
THEN p.FinalOut
ELSE NULL
END AS FilteredLastOut
FROM Passages p
)
SELECT
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
LTRIM(RTRIM(
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
)) AS [Сотрудник],
ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность],
ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№],
CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата],
ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня],
CASE
WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL
THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20))
ELSE N'—'
END AS [Первая_активность],
CASE
WHEN pass.FilteredLastOut IS NOT NULL
THEN CAST(CONVERT(VARCHAR(8), pass.FilteredLastOut, 108) AS NVARCHAR(20))
ELSE N'Нет выхода'
END AS [Конец_дня],
CASE
WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
ELSE @EndDate
END) / 60 AS VARCHAR), 2) + ':' +
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
ELSE @EndDate
END) % 60 AS VARCHAR), 2)
ELSE N'00:00'
END AS [Находился_в_здании],
CASE
WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал'
ELSE N'Отсутствовал (Нет событий)'
END AS [Статус]
FROM pList p WITH (NOLOCK)
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
LEFT JOIN EvaluatedPassages pass ON p.ID = pass.EmployeeID
WHERE
ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
AND p.Name NOT LIKE N'бр.%'
AND p.Name NOT LIKE N'Гость%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
ORDER BY p.Name ASC;
"""
# 2. Сырые события физических проходов турникетов
SQL_RAW_EVENTS_QUERY = r"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME);
DECLARE @EndDate DATETIME = {end_datetime_sql};
SELECT
log.TimeVal,
log.HozOrgan,
LTRIM(RTRIM(
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
)) AS [Сотрудник],
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
log.Event,
log.Mode,
log.DoorIndex,
CASE
WHEN log.Mode = 2 THEN 'OUT'
WHEN log.Mode = 1 THEN 'IN'
WHEN log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
WHEN log.Event IN (1, 21, 26, 54, 64) THEN 'IN'
ELSE 'OTHER'
END AS Direction
FROM pLogData log WITH (NOLOCK)
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event IN (28, 32)
-- ⭐️ Фильтрация сотрудников Ленморниипроект (исключение арендаторов и гостей)
AND ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
AND p.Name NOT LIKE N'бр.%'
AND p.Name NOT LIKE N'Гость%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
ORDER BY log.TimeVal ASC;
"""
-89
View File
@@ -1,89 +0,0 @@
"""
===============================================================================
FILE: services/scud_etl/svodka_generator.py
ROLE: Генератор Ежедневной Сводки (оперативный контроль, текущий срез).
===============================================================================
"""
import os
import logging
from typing import Dict, Any, Optional
import pandas as pd
from config import DATE_TODAY
from core.database import load_scud_from_db_by_snapshot
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.snapshots.finder import find_or_create_snapshot_for_time
from services.excel_exporter import generate_summary_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("SVODKA_GENERATOR")
def generate_svodka_service(
target_date: Optional[str] = None,
target_time: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует оперативную сводку:
- По умолчанию берет ПОСЛЕДНИЙ готовый снапшот из базы SQLite (без долгого опроса MS SQL).
"""
date_clean = str(target_date or DATE_TODAY).replace('_', '.')
applied_note = ""
# 1. Загрузка среза СКУД строго из готовых в базе
if snapshot_id:
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
elif target_time:
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=False)
applied_note = note
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=found_id)
else:
# По умолчанию: берем самый свежий существующий срез за дату
df_scud = load_best_snapshot_for_date(date_clean, prefer_final_y=False)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Срез СКУД за {date_clean} не найден в базе данных."
}
# Извлекаем время фактического среза для имени файла
actual_snap_time = ""
if 'snapshot_time' in df_scud.columns and not df_scud.empty:
raw_st = str(df_scud['snapshot_time'].iloc[0]).strip()
if " " in raw_st:
actual_snap_time = raw_st.split()[1][:5].replace(':', '-')
# 2. Загружаем актуальные кадровые данные 1С (штат + отсутствия + реестры)
df_staff, df_abs = load_1c_files_for_date(date_clean)
# 3. Слияние и расчет
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
metrics = calculate_summary_metrics(df_merged)
anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud)
# 4. Формирование книги Excel
time_suffix = f" на {actual_snap_time}" if actual_snap_time else ""
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "SVODKA",
"date": date_clean,
"target_time": actual_snap_time,
"snapshot_id": snapshot_id or "LATEST_READY",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"metrics": metrics,
"anomalies_count": len(anomalies),
"note": applied_note,
"message": f"Ежедневная сводка на {date_clean} успешно сформирована."
}
+84 -287
View File
@@ -1,11 +1,6 @@
"""
===============================================================================
FILE: services/scud_export.py
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
Корректная фильтрация транзитных проходов турникетов парковки и двора.
Учет только левого PERCo (DoorIndex = 1), правого PERCo (DoorIndex = 2)
и входа через Флигель (DoorIndex = 23).
===============================================================================
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel.
Добавлена фиксация Первой Активности (без учета направления) и среза по времени.
"""
import argparse
@@ -15,19 +10,12 @@ import sys
import warnings
from datetime import datetime, timedelta
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
import pandas as pd
import pyodbc
import xlsxwriter
from openpyxl.utils import get_column_letter
from services.scud_etl.sql_queries import SQL_QUERY_TEMPLATE, SQL_RAW_EVENTS_QUERY
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot
from core.repositories.scud_repo import save_raw_events_to_db
warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable")
@@ -40,20 +28,14 @@ LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
logger = logging.getLogger("scud_export")
logger.setLevel(logging.INFO)
logger.propagate = False
if not logger.handlers:
try:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
except (PermissionError, OSError) as e:
sys.stderr.write(f"Предупреждение: невозможно создать лог-файл {LOG_FILE}: {e}\n")
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
_console_handler = logging.StreamHandler(sys.stdout)
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
_console_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
logger.addHandler(_console_handler)
@@ -70,7 +52,7 @@ def log(message: str, level: str = "INFO"):
logger.log(level_map.get(level, logging.INFO), message)
SERVER_NAME = r"172.16.200.147\SQL"
SERVER_NAME = r"172.16.31.221\SQL"
DATABASE_NAME = "Orion-14.01.21-1"
SQL_USER = "sa"
SQL_PASSWORD = "123456"
@@ -81,42 +63,25 @@ DECLARE @InputDate DATE = '{target_date}';
DECLARE @TargetDate DATE = @InputDate;
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
DECLARE @EndDate DATETIME = {end_datetime_sql};
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
WITH PercoPassages AS (
-- Физические факты прохода (Event = 32)
WITH DailyLogs AS (
SELECT
log.HozOrgan AS EmployeeID,
log.TimeVal,
log.Event,
log.Mode,
CASE
WHEN log.Mode = 1 THEN 'IN'
WHEN log.Mode = 2 THEN 'OUT'
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
ELSE 'OTHER'
END AS Direction
END AS Direction,
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
FROM pLogData log WITH (NOLOCK)
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event = 32
AND log.Mode IN (1, 2)
AND (
-- Контур 1: Левый турникет открыт для всех
log.DoorIndex = 1
OR
-- Контур 2: Правый турникет разрешен только для реестра двора
(
log.DoorIndex = 2
AND ({turnstile_filter_sql})
)
OR
-- Контур 3: Флигель 1 эт. разрешен только для реестра флигеля
(
log.DoorIndex = 23
AND ({fligel_filter_sql})
)
)
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
),
Passages AS (
SELECT
@@ -124,21 +89,10 @@ Passages AS (
MIN(TimeVal) AS FirstRawEvent,
MAX(TimeVal) AS LastRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS FinalOut
FROM PercoPassages
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
FROM DailyLogs
GROUP BY EmployeeID
),
EvaluatedPassages AS (
SELECT
p.*,
CASE
WHEN p.FinalOut IS NOT NULL
AND p.FirstIn IS NOT NULL
AND p.FinalOut > DATEADD(MINUTE, 5, p.FirstIn)
THEN p.FinalOut
ELSE NULL
END AS FilteredLastOut
FROM Passages p
)
SELECT
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
@@ -160,8 +114,12 @@ SELECT
ELSE N'—'
END AS [Первая_активность],
CASE
WHEN pass.FilteredLastOut IS NOT NULL
THEN CAST(CONVERT(VARCHAR(8), pass.FilteredLastOut, 108) AS NVARCHAR(20))
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
THEN N'Нет выхода'
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
ELSE N'Нет выхода'
END AS [Конец_дня],
CASE
@@ -169,14 +127,14 @@ SELECT
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
ELSE @EndDate
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
END) / 60 AS VARCHAR), 2) + ':' +
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
ELSE @EndDate
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
END) % 60 AS VARCHAR), 2)
ELSE N'00:00'
END AS [Находился_в_здании],
@@ -187,7 +145,7 @@ SELECT
FROM pList p WITH (NOLOCK)
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
LEFT JOIN EvaluatedPassages pass ON p.ID = pass.EmployeeID
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID
WHERE
ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL
@@ -204,113 +162,33 @@ WHERE
ORDER BY p.Name ASC;
"""
SQL_RAW_EVENTS_QUERY = r"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME);
DECLARE @EndDate DATETIME = {end_datetime_sql};
SELECT
log.TimeVal,
log.HozOrgan,
LTRIM(RTRIM(
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
)) AS [Сотрудник],
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
log.Event,
log.Mode,
log.DoorIndex,
CASE
WHEN log.Mode = 2 THEN 'OUT'
WHEN log.Mode = 1 THEN 'IN'
WHEN log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
WHEN log.Event IN (1, 21, 26, 54, 64) THEN 'IN'
ELSE 'OTHER'
END AS Direction
FROM pLogData log WITH (NOLOCK)
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event IN (28, 32)
-- ⭐️ Фильтрация сотрудников Ленморниипроект (исключение арендаторов и гостей)
AND ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
AND p.Name NOT LIKE N'бр.%'
AND p.Name NOT LIKE N'Гость%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
ORDER BY log.TimeVal ASC;
"""
def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0):
from openpyxl import load_workbook
wb = load_workbook(file_path)
ws = wb[sheet_name]
for col_cells in ws.columns:
max_len = 0
col_letter = get_column_letter(col_cells[0].column)
for cell in col_cells:
if cell.value is not None:
cell_len = len(str(cell.value))
if cell_len > max_len:
max_len = cell_len
width = max(min_width, min(max_len + padding, max_width))
ws.column_dimensions[col_letter].width = width
wb.save(file_path)
def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"):
workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False})
worksheet = workbook.add_worksheet(sheet_name)
fmt_header = workbook.add_format({
'bold': True,
'bg_color': '#D9E1F2',
'border': 1,
'border_color': '#D3D3D3',
'align': 'center',
'valign': 'vcenter',
'font_name': 'Calibri',
'font_size': 11
})
fmt_cell = workbook.add_format({
'border': 1,
'border_color': '#D3D3D3',
'valign': 'vcenter',
'align': 'left',
'font_name': 'Calibri',
'font_size': 11
})
headers = list(df.columns)
col_widths = [len(str(h)) for h in headers]
for col_idx, header in enumerate(headers):
worksheet.write(0, col_idx, str(header), fmt_header)
for row_idx, row_values in enumerate(df.values, start=1):
for col_idx, val in enumerate(row_values):
if pd.isna(val) or val is None:
val_str = ""
elif isinstance(val, bool):
val_str = "Да" if val else "Нет"
else:
val_str = str(val)
worksheet.write(row_idx, col_idx, val_str, fmt_cell)
if len(val_str) > col_widths[col_idx]:
col_widths[col_idx] = len(val_str)
for col_idx, width in enumerate(col_widths):
worksheet.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
workbook.close()
def get_targets(input_date: str | None, input_time: str | None = None):
def get_targets(input_date: str | None):
targets = []
if input_date:
try:
parsed = datetime.strptime(input_date.replace('_', '.'), "%d.%m.%Y").date()
targets.append({"name": "Указанная дата", "date": parsed, "target_time": input_time})
parsed = datetime.strptime(input_date, "%d.%m.%Y").date()
targets.append({"name": "Указанная дата", "date": parsed})
except ValueError:
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
sys.exit(1)
@@ -319,13 +197,13 @@ def get_targets(input_date: str | None, input_time: str | None = None):
yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date()
today = now.date()
targets.append({"name": "Вчера", "date": yesterday, "target_time": None})
targets.append({"name": "Сегодня", "date": today, "target_time": None})
targets.append({"name": "Вчера", "date": yesterday})
targets.append({"name": "Сегодня", "date": today})
return targets
def run_export(input_date: str | None = None, input_time: str | None = None, save_xlsx: bool = True, debug: bool = False):
def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False):
if debug:
logger.setLevel(logging.DEBUG)
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
@@ -333,27 +211,7 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
os.makedirs(SCUD_DIR, exist_ok=True)
# ⭐️ 1. ПОЛУЧАСОВАЯ СИНХРОНИЗАЦИЯ 1С:ЗУП (Файлы с шары + База данных)
try:
log("[1C] Проверка сетевой шары и синхронизация файлов 1С...")
copy_1c_files_from_share()
target_sync_date = input_date or datetime.now().strftime("%d.%m.%Y")
# Прямой опрос свежих отсутствий и сохранение в SQLite
df_fresh_abs = load_absent_data(target_sync_date)
if df_fresh_abs is not None and not df_fresh_abs.empty:
save_absences_to_db(df_fresh_abs, target_sync_date)
log(f"[1C] [✓] Актуализировано {len(df_fresh_abs)} отсутствий в SQLite за {target_sync_date}", "SUCCESS")
# Если на шаре появился свежий штат — также обновляем его в базе
df_fresh_staff = load_staff_data(target_sync_date)
if df_fresh_staff is not None and not df_fresh_staff.empty:
save_staff_to_db(df_fresh_staff, target_sync_date)
log(f"[1C] [✓] Актуализирован штат ({len(df_fresh_staff)} чел.) в SQLite за {target_sync_date}", "SUCCESS")
except Exception as e:
log(f"[1C] ⚠️ Ошибка фоновой синхронизации 1С: {e}", "WARNING")
targets = get_targets(input_date, input_time)
targets = get_targets(input_date)
conn_str = (
f"DRIVER={{{ODBC_DRIVER}}};"
f"SERVER={SERVER_NAME};"
@@ -369,83 +227,21 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
processing_date = target["date"]
processing_date_str = processing_date.strftime("%d.%m.%Y")
period_label = target["name"]
target_time = target["target_time"]
today_date = datetime.now().date()
is_past_day = (processing_date < today_date) or (period_label == "Вчера")
is_yesterday = (period_label == "Вчера")
if is_past_day and not target_time and has_yesterday_final_snapshot(processing_date_str):
log(f"[ℹ️] День ({processing_date_str}) уже зафиксирован финишным снапшотом _FINAL. Пропускаем.")
# ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE:
# Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL
if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
continue
exc_data = load_exceptions()
# 1. Формируем фильтр правого турникета (DoorIndex = 2)
t_fios = [f.replace("'", "''") for f in exc_data.get('turnstile_fio', []) if f]
t_depts = [d.replace("'", "''") for d in exc_data.get('turnstile_departments', []) if d]
full_fio_sql = (
"LTRIM(RTRIM("
"ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') + "
"CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END + "
"CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END"
"))"
)
conditions = []
if t_fios:
fio_in = ", ".join([f"N'{f}'" for f in t_fios])
conditions.append(f"{full_fio_sql} IN ({fio_in})")
if t_depts:
dept_in = ", ".join([f"N'{d}'" for d in t_depts])
conditions.append(f"ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') IN ({dept_in})")
turnstile_filter_sql = " OR ".join(conditions) if conditions else "1 = 0"
# 1.1 Формируем фильтр Флигеля (DoorIndex = 23)
fl_fios = [f.replace("'", "''") for f in exc_data.get('fligel_fio', []) if f]
fl_depts = [d.replace("'", "''") for d in exc_data.get('fligel_departments', []) if d]
fl_conditions = []
if fl_fios:
fl_fio_in = ", ".join([f"N'{f}'" for f in fl_fios])
fl_conditions.append(f"{full_fio_sql} IN ({fl_fio_in})")
if fl_depts:
fl_dept_in = ", ".join([f"N'{d}'" for d in fl_depts])
fl_conditions.append(f"ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') IN ({fl_dept_in})")
fligel_filter_sql = " OR ".join(fl_conditions) if fl_conditions else "1 = 0"
# 2. Безопасное математическое определение @EndDate через DATEADD
if target_time:
t_clean = target_time.strip()
t_parts = t_clean.split(":")
h = int(t_parts[0])
m = int(t_parts[1]) if len(t_parts) > 1 else 0
s = int(t_parts[2]) if len(t_parts) > 2 else 0
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} {h:02d}:{m:02d}:{s:02d}"
end_datetime_sql = f"DATEADD(SECOND, {s}, DATEADD(MINUTE, {m}, DATEADD(HOUR, {h}, @StartDate)))"
is_final = False
elif is_past_day:
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 23:59:59"
end_datetime_sql = "DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate))"
is_final = True
if is_yesterday:
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00"
else:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
end_datetime_sql = "GETDATE()"
is_final = False
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Срез: {snapshot_time}]")
sql_query = SQL_QUERY_TEMPLATE.format(
target_date=processing_date.strftime("%Y-%m-%d"),
end_datetime_sql=end_datetime_sql,
turnstile_filter_sql=turnstile_filter_sql,
fligel_filter_sql=fligel_filter_sql
)
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]")
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
connection = None
try:
@@ -463,18 +259,7 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly)
save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_final)
raw_sql = SQL_RAW_EVENTS_QUERY.format(
target_date=processing_date.strftime("%Y-%m-%d"),
end_datetime_sql=end_datetime_sql
)
df_raw = pd.read_sql(raw_sql, connection)
if len(df_raw) > 0:
df_raw['fio_clean'] = df_raw['Сотрудник'].apply(clean_scud_fio_light)
inserted_count = save_raw_events_to_db(df_raw, processing_date_str)
log(f"[✓] В scud_events_raw сохранено {inserted_count} сырых событий за {processing_date_str}!", "SUCCESS")
save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday)
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
if save_xlsx:
@@ -487,7 +272,8 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
except OSError as e:
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
save_df_to_clean_excel(df, file_path, sheet_name="Отчет")
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl")
auto_fit_columns(file_path, sheet_name="Отчет")
log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
else:
log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
@@ -504,11 +290,22 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--date", dest="input_date", default=None, help="Дата среза (ДД.ММ.ГГГГ)")
parser.add_argument("--time", dest="input_time", default=None, help="Время среза (ЧЧ:ММ)")
parser.add_argument("-d", "--debug", action="store_true")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True)
help_text = """
Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel.
ПРИМЕРЫ ЗАПУСКА:
python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера
python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату
python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX
python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)")
parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/")
args = parser.parse_args()
run_export(args.input_date, input_time=args.input_time, save_xlsx=args.save_xlsx, debug=args.debug)
run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
+1 -10
View File
@@ -1,10 +1,3 @@
"""
===============================================================================
FILE: services/share_copier.py
ROLE: Синхронизация файлов 1С (Штат и Отсутствия) с сетевой шары в data/1c/.
===============================================================================
"""
import os
import shutil
from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR
@@ -59,9 +52,7 @@ def copy_1c_files_from_share():
local_target_path = os.path.join(ZUP_1C_DIR, filename)
try:
# shutil.copyfile копирует только содержимое потока байтов
# без попыток изменить POSIX-права/атрибуты (chmod) на CIFS/SMB шаре
shutil.copyfile(remote_file, local_target_path)
shutil.copy2(remote_file, local_target_path)
print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/")
copied_count += 1
except Exception as e:
View File

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