88 changed files with 13246 additions and 16368 deletions
-5
View File
@@ -27,8 +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/
+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": []}
-22
View File
@@ -5,10 +5,6 @@ ROLE: Фасад ядра базы данных с полной обратной
===============================================================================
"""
import os
import pandas as pd
from config import OUTPUT_DIR
from core.connection import get_connection, DB_PATH
from core.schema import init_all_tables
@@ -37,21 +33,3 @@ from core.repositories.zup_repo import (
)
init_db = init_all_tables
def dump_database_to_excel(out_filename: str = "db_dump_full.xlsx") -> str:
"""Создает полный дамп всех ключевых таблиц SQLite в многостраничный Excel."""
out_path = os.path.join(OUTPUT_DIR, out_filename)
tables = [
'scud_logs', 'scud_events_raw', 'zup_staff', 'zup_absences',
'anomalies_history', 'ai_knowledge_base', 'chat_messages',
'session_states', 'system_prompt_nodes', 'tasks',
'exceptions_registry', 'manual_absences', 'person_identity_mapping'
]
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in tables:
try:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
except Exception:
pass
return out_path
+36 -196
View File
@@ -21,50 +21,32 @@ 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
""",
"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: str, date_str: str = None, is_yesterday: bool = False) -> str:
"""
Генерирует понятный и уникальный ID снапшота:
- Дата префикса берется строго из даты самих логов (date_str).
- Для итоговых срезов дня: YYYYYMMDD_FINAL (строго с буквой Y в начале).
- Для дневных срезов на время: YYYYMMDD_HHMM.
"""
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")
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]}"
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()
# Если срез с точно таким же временем и датой уже существует — возвращаем его 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",
@@ -80,51 +62,28 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste
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}-%",))
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}-%"))
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
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
next_seq = max_seq + 1
return f"{base_id}-{next_seq:03d}"
return f"{prefix}{date_prefix}-{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_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
@@ -142,8 +101,7 @@ def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = N
1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'),
snapshot_time,
snapshot_id,
now_local_str # ⭐️ Передаем локальное время машины напрямую
snapshot_id
)
for _, r in df_scud.iterrows()
]
@@ -155,9 +113,9 @@ def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = N
INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time, snapshot_id, created_at
is_present, anomaly_flag, snapshot_time, snapshot_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
@@ -176,56 +134,27 @@ def get_latest_snapshot_time(date_str: str = 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),)
)
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,)
)
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()
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,)
)
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_id = row[0]
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(target_id,)
)
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
if not df.empty:
rename_map = {
'department': 'department_scud',
'position': 'Должность',
'fio': 'Сотрудник',
'time_in': 'Начало_дня',
'first_activity': 'Первая_активность',
'time_out': 'Конец_дня',
'time_in_building': 'Находился_в_здании',
'is_present': 'Пришел'
'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})
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 '—'
@@ -239,12 +168,7 @@ 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
SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as cnt
FROM scud_logs
WHERE snapshot_time IS NOT NULL
"""
@@ -273,87 +197,3 @@ def delete_snapshots_by_date(date_str: str) -> int:
cnt = cursor.rowcount
conn.commit()
return cnt
def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int:
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
+5 -50
View File
@@ -38,24 +38,6 @@ def init_all_tables() -> None:
);
""")
# 1.1 Сырые физические события турникетов СКУД (для перекуров и оперативного статуса)
cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_events_raw (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_date TEXT NOT NULL,
time_val TEXT NOT NULL,
hoz_organ INTEGER NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
event_code INTEGER NOT NULL,
mode INTEGER NOT NULL,
door_index INTEGER DEFAULT 1,
direction TEXT NOT NULL, -- 'IN' или 'OUT'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 2. Кадровые реестры 1С
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_staff (
@@ -80,7 +62,7 @@ def init_all_tables() -> None:
);
""")
# 3. Аномалии, база знаний и синонимы
# 3. Аномалии и база знаний
cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -111,34 +93,7 @@ def init_all_tables() -> None:
);
""")
# 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. Узлы системного промпта, сессии, сообщения и задачи
# 4. Узлы системного промпта и сессии
cursor.execute("""
CREATE TABLE IF NOT EXISTS system_prompt_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -172,6 +127,7 @@ def init_all_tables() -> None:
);
""")
# 5. Задачи
cursor.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -189,8 +145,7 @@ def init_all_tables() -> None:
# 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);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
conn.commit()
Binary file not shown.
Binary file not shown.
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 -141
View File
@@ -1,137 +1,6 @@
# Changelog
# 📋 История изменений (CHANGELOG)
Все важные изменения проекта документируются в этом файле.
## [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`).
- Добавлена защита инициализации файлового логгера от ошибок доступа прав пользователей.
---
Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6].
## [3.0.0] — 2026-08-21
### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture)
@@ -202,28 +71,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].
+86 -159
View File
@@ -1,196 +1,123 @@
# План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 2].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 2].
## 1. Очистка от регулярок и костылей (`tool_injector.py`)
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 4].
---
## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]`
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 2].
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию[cite: 2]:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 2].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 2].
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 2].
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 2].
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 2].
## 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. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
- [x] **Шаг 1. Выделение общего слоя ядра (`core/`)**
- [x] Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов.
- [x] DDL-схемы таблиц и индексов (`core/schema.py`).
- [x] Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`).
- [x] Фасад обратной совместимости (`core/database.py`).
- [x] **Шаг 2. Декомпозиция предметных доменов (`services/`)**
- [x] Сервис задач (`services/tasks/`): CRUD, валидация, markdown-экспорт (`exporter.py`).
- [x] Сервис системного промпта (`services/prompts/`): сборка узлов, вычисление diff (`diff_engine.py`), пакетное удаление (`BATCH_DELETE`).
- [x] Сервис снапшотов СКУД (`services/snapshots/`): безопасное удаление с защитой Y-снапшотов.
- [x] Сервис базы знаний (`services/knowledge/`): правила арбитража и синонимы отделов.
- [x] **Шаг 3. Рефакторинг ETL-конвейера (`services/scud_etl/`)**
- [x] Оркестратор этапов контроллинга (`pipeline.py`).
- [x] Детектор аномалий и конфликтов реестров (`anomaly_detector.py`).
- [x] Агрегация проходов и наложение исключений (`merger.py`).
- [x] Информативное консольное логирование этапов в `main_etl.py`.
- [x] **Шаг 4. Модульный ИИ-оркестратор (`modules/ai_engine/`)**
- [x] Динамический строитель системного контекста (`context_builder.py`).
- [x] Изолированные обработчики инструментов (`handlers/task_handler.py`, `handlers/prompt_handler.py`, `handlers/snapshot_handler.py`).
- [x] Компактный диспетчер диалога (`agent.py`).
- [x] Двухконтурный семантический анализатор намерений (`tool_injector.py`).
- [x] **Шаг 5. Зачистка рудиментов и стабилизация**
- [x] Устранение дубликатов в `web_api/llm/db/`.
- [x] Очистка роутеров от прямых манипуляций с базой данных.
- [x] Исправление сигнатур Fast-Path перехватчиков.
---
## 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_report(date_str, report_type)` для генерации сводки или детального отчета по требованию оператора.
- [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`).
- [ ] **Диалоговый аудит расхождений**
- [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера».
- [ ] Быстрое внесение исключений и синонимов подразделений через диалог.
---
## 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. Внутридневной контроль и генерация отчетов из веб-интерфейса `[В РАБОТЕ]`
- [ ] **Моментальное создание срезов из UI:**
- [ ] Кнопка **«Создать моментальный срез»** в шапке хаба «Срезы».
- [ ] Вызов `scud_export.py` с фиксацией состояния на текущую минуту и добавлением среза в список без перезагрузки страницы.
- [ ] **Контекстные кнопки генерации в строках срезов:**
- [ ] Размещение кнопок быстрых действий напротив каждой карточки среза в сайдбаре:
- Для промежуточных срезов (`HH:00`): кнопка **«Сводка»** $\rightarrow$ запуск `svodka_generator.py` на момент среза.
- Для итоговых срезов (`_FINAL` / `Y`): кнопка **«Отчет»** $\rightarrow$ запуск `otchet_generator.py` со сверкой 1С:ЗУП за сутки.
- [ ] Фоновая сборка документа с отображением индикатора и автоматической выдачей ссылки на скачивание файла (`/api/v1/files/download/...`).
- [ ] **Выделенный экран оперативного мониторинга («Текущая сводка»):**
- [ ] Отдельная страница/вкладка оперативного контроля присутствия в реальном времени:
- Метрики: *Всего по штату*, *В здании прямо сейчас*, *На удаленке*, *В командировке*, *Отсутствуют без причины*.
- Быстрый поиск и фильтрация по подразделениям.
- Подсветка аномалий внутри дня (вход без выхода более 10 часов, активность без прохода турникета).
---
## 15. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]`
- [ ] **Двухконтурный вызов ИИ:** перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`[cite: 2].
- [ ] **Якорный табельный номер (TabNo Matching):**
- [ ] Извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП[cite: 2].
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite[cite: 2].
- [ ] Защита от смены фамилий и опечаток через инвариант табельного номера[cite: 2].
- [ ] **4-уровневая система предохранителей (Guardrails):**
- Уровень 1: Точный матч ФИО (100%)[cite: 2].
- Уровень 2: Матч по табельному номеру при расхождении ФИО[cite: 2].
- Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С[cite: 2].
- Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш[cite: 2].
---
## 16. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
## 10. Изолированная песочница кода (Code Execution Sandbox Engine)
- [ ] **Docker/gVisor контур:**
- Создание изолированного контейнера без доступа к внешней сети (`network: none`) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 2].
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 2].
- Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4].
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4].
- [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 2].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 2].
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4].
-4655
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -9,6 +9,7 @@
"fio": [
"Таткало Валерий Валерьевич",
"Петренюк Андрей Германович",
"Чуркина Елена Геннадьевна",
"Михалев Сергей Геннадьевич"
],
"position_keywords": [
@@ -17,9 +18,5 @@
"дворник",
"гардероб",
"рабочий по обслуживанию"
],
"include_fio": [
"Тарасенко Александр Александрович",
"Журиков Михаил Николаевич"
]
}
+19 -135
View File
@@ -8,120 +8,38 @@ ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1
import os
import sys
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.text_reporter.service import format_controlling_summary_markdown
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
# Корректные импорты из папки services/
from services.scud_export import run_scud_export_today_and_yesterday
from services.share_copier import sync_1c_files_from_share
from services.excel_exporter import build_daily_summary_excel, build_detailed_yesterday_excel
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
def print_help():
print("""
===============================================================================
🛠️ SCUD ORION AI — СИСТЕМА КОНТРОЛЛИНГА И СВОДНЫХ ОТЧЕТОВ
===============================================================================
Использование:
python main_etl.py [ОПЦИИ]
Доступные аргументы:
-h, --help, help Показать эту справку и выйти
--date ДД.ММ.ГГГГ Дата расчета (по умолчанию: текущий рабочий день)
--time ЧЧ:ММ Время среза для сводки (например: 14:30)
Ищет ближайший срез (±20 мин) или запрашивает On-Demand экспорт
--snapshot ID Точный ID снапшота для расчета (например: 20260827-002)
--skip-export Пропустить выгрузку СКУД из MS SQL (работать только с SQLite)
--export-only ТОЛЬКО сделать экспорт/снапшот СКУД в БД без построения отчетов
-d, --debug Включить режим расширенной отладки
Примеры использования:
python main_etl.py
👉 Полный суточный цикл: экспорт -> отчет за вчера -> сводка за сегодня -> ИИ.
python main_etl.py --export-only
👉 Почасовой тихий срез в БД (для cron) без генерации отчетов.
python main_etl.py --date 27.08.2026 --time 12:15 --skip-export
👉 Построить сводку за 27.08 на 12:15 без запроса к внешнему MS SQL.
python main_etl.py --snapshot 20260827-001 --skip-export
👉 Расчет отчетов строго по выбранному снапшоту из SQLite.
===============================================================================
""")
def main():
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
print_help()
sys.exit(0)
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)
print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}")
print("ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С")
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} строк промежуточных архивных срезов.")
now = datetime.now()
if args.date:
today_str = args.date.replace('_', '.')
dt_target = datetime.strptime(today_str, "%d.%m.%Y")
days_back = 3 if dt_target.weekday() == 0 else 1
yesterday_str = (dt_target - timedelta(days=days_back)).strftime("%d.%m.%Y")
else:
today_str = now.strftime("%d.%m.%Y")
if now.weekday() == 0:
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
else:
yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
# [Этап 0] Выгрузка свежих данных СКУД
if not args.skip_export and not args.snapshot:
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
run_export(input_date=args.date, debug=args.debug, save_xlsx=True)
else:
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
# Если запрошен режим тихого почасового среза — выходим без тяжелых генераций
if args.export_only:
print(f"\n[✓] Режим --export-only: срез зафиксирован в SQLite. Генерация отчетов пропущена.")
sys.exit(0)
run_scud_export_today_and_yesterday()
# [Этап 0.5] Синхронизация файлов с шары 1С
if not args.snapshot and not args.skip_export:
print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
copy_1c_files_from_share()
else:
print("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...")
sync_1c_files_from_share()
# [Этап 1-2] Загрузка данных
print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...")
@@ -131,55 +49,21 @@ def main():
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] Детальный отчет за вчера через otchet_generator
# [Этап 3] Детальный отчет за вчера (на базе финального Y-снапшота)
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')}")
# [Этап 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')}")
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
build_detailed_yesterday_excel(df_merged_yesterday, yesterday_str)
# [Этап 4] Сводка за сегодня
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...")
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)
metrics_today = calculate_summary_metrics(df_merged_today)
anomalies_today = detect_registry_anomalies(df_merged_today)
build_daily_summary_excel(df_merged_today, metrics_today, today_str)
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() == '')) &
(df_merged_today.get('is_excluded', 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
)
# [Этап 5] Формирование текстового отчета и вывод в консоль
print(f"\n[5/5] Формирование Markdown-сводки...")
summary_md = format_controlling_summary_markdown(today_str, metrics_today, anomalies_today)
os.makedirs("output", exist_ok=True)
md_file_path = f"output/Сводка_контроллинга_{today_str}.md"
+178
View File
@@ -0,0 +1,178 @@
"""
===============================================================================
FILE: modules/ai_engine/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine
ROLE: Лаконичный нативный оркестратор Function Calling, диспетчер handlers
и менеджер свободных диалогов (Topic Drift).
AI-CONTEXT-ANCHORS:
- ANCHOR[AGENT_PIPELINE_ENTRY]: Главная точка входа process_chat_message.
===============================================================================
"""
import sys
import json
import logging
from typing import List, Dict, Any, Tuple, Optional
from modules.web_api.llm.schemas import TOOLS_SCHEMA
from modules.web_api.llm.core.ollama_client import call_ollama_chat
from modules.web_api.llm.core.fast_path import handle_fast_path_intercept
from modules.web_api.llm.core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
from modules.web_api.llm.core.context_manager import mark_last_user_message_ephemeral, close_tool_session_and_cleanup
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
from modules.web_api.llm.db.db_prompts import (
db_get_session_state, db_clear_session_state, db_set_session_state,
db_get_stats, db_get_anomalies, db_get_reference
)
from services.knowledge.service import get_rules
from .context_builder import build_agent_system_context
from .handlers.task_handler import handle_tasks_call
from .handlers.prompt_handler import handle_prompt_call
from .handlers.snapshot_handler import handle_snapshots_call
logger = logging.getLogger("AI_AGENT")
logger.setLevel(logging.INFO)
# ANCHOR[AGENT_PIPELINE_ENTRY]
def process_chat_message(
user_id: int,
user_message: str,
file_context: str = "",
image_b64: Optional[str] = None,
chat_history: List[Dict[str, Any]] = None,
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Главный конвейер обработки сообщений чата."""
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
session_state = db_get_session_state(session_id)
if not session_state:
db_purge_ephemeral_messages(session_id)
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
# 1. Быстрый Fast-Path перехват кнопок подтверждения
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_history = db_get_chat_history(session_id, limit=20)
system_prompt = build_agent_system_context(user_id, session_state)
user_msg_obj = {"role": "user", "content": full_user_content}
try:
if image_b64:
user_msg_obj["images"] = [image_b64]
messages = [{"role": "system", "content": "Строгий модуль OCR. Перепиши весь текст буква в букву."}, user_msg_obj]
msg = call_ollama_chat(messages, is_vision=True)
else:
clean_history = [dict(m) for m in db_history]
for m in clean_history: m.pop("images", None)
messages = [{"role": "system", "content": system_prompt}] + clean_history + [user_msg_obj]
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
raw_reply = msg.get("content", "")
tool_calls = msg.get("tool_calls", [])
# 2. Гибридный семантический классификатор намерений (Fallback Safety Net)
tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls)
# 3. Исполнение инструментов через изолированные handlers
if tool_calls:
tool = tool_calls[0]
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
if isinstance(fn_args, str):
try: fn_args = json.loads(fn_args)
except Exception: fn_args = {}
logger.info(f"Вызов инструмента: {fn_name} с аргументами: {fn_args}")
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
mark_last_user_message_ephemeral(session_id)
state_data = session_state.get("data_json") or {} if session_state else {}
if fn_name in ["db_get_tasks", "db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
return handle_tasks_call(fn_name, fn_args, user_id, session_id)
elif fn_name in ["db_get_system_prompt", "db_prompt_node_edit"]:
return handle_prompt_call(fn_name, fn_args, session_id)
elif fn_name in ["db_get_snapshots", "db_delete_snapshots"]:
return handle_snapshots_call(fn_name, fn_args, session_id, user_message, state_data)
elif fn_name == "db_get_rules":
res_str = json.dumps(get_rules(), ensure_ascii=False)
elif fn_name == "db_get_stats":
res_str = json.dumps(db_get_stats(), ensure_ascii=False)
elif fn_name == "db_get_anomalies":
res_str = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
elif fn_name == "db_get_reference":
res_str = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
else:
res_str = "{}"
messages.append(msg)
messages.append({"role": "tool", "content": res_str})
sec_msg = call_ollama_chat(messages, is_vision=False)
final_content = clean_raw_tool_tags(clean_output(sec_msg.get("content", ""))) or "Запрос выполнен."
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
return final_content, db_get_chat_history(session_id), None
# 4. Обычный содержательный диалог и управление Topic Drift
final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан."
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто,", "почемучто", "почему-то"]:
if final_reply.lower().startswith(artifact):
final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
action_payload = None
# Обработка ответа "нет / спасибо" в режиме открытого инструмента
if session_state and any(kw in user_message.lower() for kw in ["нет", "спасибо", "не надо", "готово", "хватит"]):
close_tool_session_and_cleanup(session_id, close_reason="USER_DISMISSED_TOOL")
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), None
# Инкремент счётчика шагов в сторону от инструмента (Topic Drift)
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW", "SNAPSHOTS_VIEW"]:
state_type = session_state.get("state_type")
state_data = session_state.get("data_json") or {}
if not isinstance(state_data, dict):
state_data = {}
idle_turns = state_data.get("idle_turns", 0) + 1
state_data["idle_turns"] = idle_turns
if idle_turns >= 4:
# 4-й шаг не по теме: бесшумно закрываем сессию и вычищаем эфемерные карточки
close_tool_session_and_cleanup(session_id, close_reason="TOPIC_DRIFT_TIMEOUT")
elif idle_turns == 3:
# 3-й шаг: выводим вежливое напоминание с кнопками
tool_label = "системным промптом" if "PROMPT" in state_type else "снапшотами СКУД"
guard_question = f"Желаете продолжить работу с {tool_label}?"
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
action_payload = {
"type": "FOLLOW_UP_ACTION",
"buttons": [
{"label": "Показать снова", "value": "покажи системный промпт" if "PROMPT" in state_type else "покажи снапшоты", "style": "primary"},
{"label": "Завершить", "value": "нет, спасибо", "style": "secondary"}
]
}
db_set_session_state(session_id, state_type, state_data)
else:
# 1-й и 2-й шаг: фиксируем обновленный счётчик
db_set_session_state(session_id, state_type, state_data)
is_ephem_reply = 1 if "актуальный системный промпт:" in final_reply.lower() else 0
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=is_ephem_reply)
return final_reply, db_get_chat_history(session_id), action_payload
except Exception as ex:
logger.exception(f"Ошибка в агенте: {ex}")
return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None
+84
View File
@@ -0,0 +1,84 @@
"""
===============================================================================
FILE: modules/ai_engine/context_builder.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine
ROLE: Динамическая сборка системного промпта из БД, календаря и контекста сессий.
AI-CONTEXT-ANCHORS:
- ANCHOR[CONTEXT_BUILDER_MAIN]: Формирование полного системного контекста для LLM.
===============================================================================
"""
from typing import Dict, Any, Optional
from services.prompts.service import get_active_system_prompt
from modules.web_api.llm.core.calendar_utils import get_dynamic_calendar_context
# ANCHOR[CONTEXT_BUILDER_MAIN]
def build_agent_system_context(
user_id: int,
session_state: Optional[Dict[str, Any]]
) -> str:
"""Формирует полный динамический системный контекст агента с правилами из БД."""
# 1. Загружаем актуальный базовый системный промпт из SQLite БД
base_system_prompt = get_active_system_prompt()
# 2. Серверный календарь и контекст дат
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
# 3. Контекст текущего активного стейта сессии
current_state_type = session_state.get("state_type") if session_state else None
state_data = session_state.get("data_json") or {} if session_state else {}
if not isinstance(state_data, dict):
state_data = {}
active_state_context = ""
if current_state_type == "PROMPT_PREVIEW":
active_state_context = (
"\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n"
"- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n"
)
elif current_state_type == "PROMPT_FOLLOWUP":
active_state_context = (
"\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
"- Оператор просматривает или редактирует системный промпт.\n"
"- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
"- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n"
"- Запрещено путать ADD и DELETE.\n"
)
elif current_state_type == "SNAPSHOTS_VIEW":
active_date = state_data.get("query_date", "выбранную дату")
active_state_context = (
f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n"
f"- Сейчас на экране отображаются снапшоты за {active_date}.\n"
f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n"
f"- Если оператор запрашивает ДРУГУЮ дату или день недели (например: 'за вчера', 'а за 13.08') — "
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n"
f"- Запрещено генерировать текст за другую дату по памяти.\n"
)
elif current_state_type == "SNAPSHOT_DELETE_CONFIRM":
active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n"
elif current_state_type == "TASK_DELETE_CONFIRM":
active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n"
# 4. Сборка полного системного сообщения
return (
f"{base_system_prompt}\n\n"
f"СТРОГИЕ ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ:\n"
f"1. Для системного промпта:\n"
f" - 'добавь X.Y Текст' -> СРАЗУ вызывай db_prompt_node_edit(action='ADD', section_id=X, item_id=Y, content='Текст').\n"
f" - 'удали X.Y' -> СРАЗУ вызывай db_prompt_node_edit(action='DELETE', section_id=X, item_id=Y, content='').\n"
f" - 'покажи системный промпт' -> СРАЗУ вызывай db_get_system_prompt().\n"
f"2. Для задач:\n"
f" - 'удали задачу N' -> СРАЗУ вызывай db_tasks_edit(action='DELETE', task_id='N').\n"
f" - 'возьми в работу N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='IN_PROGRESS').\n"
f" - 'заверши N' / 'готово N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='COMPLETED').\n"
f"3. Запрещено задавать вопросы и писать подтверждения текстом — СРАЗУ вызывай соответствующий инструмент!\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}"
)
@@ -0,0 +1,98 @@
"""
===============================================================================
FILE: modules/ai_engine/handlers/prompt_handler.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine / handlers
ROLE: Изолированная обработка команд управления системным промптом.
===============================================================================
"""
from typing import Dict, Any, Tuple, Optional
from services.prompts.service import get_active_system_prompt, create_prompt_preview
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
from modules.web_api.llm.db.db_prompts import db_set_session_state
# ANCHOR[PROMPT_HANDLER_DISPATCH]
def handle_prompt_call(
fn_name: str,
fn_args: Dict[str, Any],
session_id: str
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
"""Обрабатывает вызовы просмотра и изменения системного промпта."""
# 1. Просмотр промпта с кнопкой быстрого перехода в редактор
if fn_name == "db_get_system_prompt":
active_prompt = get_active_system_prompt()
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
# Сохраняем состояние сессии для возможности мгновенного редактирования и подтверждения
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": active_prompt,
"action": "MANUAL_EDIT",
"idle_turns": 0
})
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": active_prompt,
"baseline_prompt": active_prompt,
"buttons": [
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"},
{"label": "Готово", "value": "нет, спасибо", "style": "secondary"}
]
}
# 2. Предпросмотр точечных или пакетных изменений (ADD / EDIT / DELETE / BATCH_DELETE)
action = str(fn_args.get("action", "ADD")).upper()
nodes_list = fn_args.get("nodes_list", [])
delete_nodes_tuples = []
if nodes_list:
for n_str in nodes_list:
parts = str(n_str).strip().split(".")
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
delete_nodes_tuples.append((int(parts[0]), int(parts[1])))
sec_id = None
itm_id = None
try:
if fn_args.get("section_id") is not None:
sec_id = int(str(fn_args.get("section_id")).strip())
if fn_args.get("item_id") is not None:
itm_id = int(str(fn_args.get("item_id")).strip())
except Exception:
pass
content = str(fn_args.get("content", "")).strip()
merged_prompt, diff_html, baseline_prompt = create_prompt_preview(
action=action,
section_id=sec_id,
item_id=itm_id,
content=content,
delete_nodes=delete_nodes_tuples if delete_nodes_tuples else None
)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": merged_prompt,
"action": "MANUAL_EDIT",
"section_id": sec_id,
"item_id": itm_id,
"content": content,
"idle_turns": 0
})
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": merged_prompt,
"baseline_prompt": baseline_prompt,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
]
}
@@ -0,0 +1,91 @@
"""
===============================================================================
FILE: modules/ai_engine/handlers/snapshot_handler.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine / handlers
ROLE: Изолированная обработка запросов просмотра и удаления срезов СКУД.
AI-CONTEXT-ANCHORS:
- ANCHOR[SNAPSHOT_HANDLER_DISPATCH]: Обработка db_get_snapshots и db_delete_snapshots.
===============================================================================
"""
from typing import Dict, Any, Tuple, Optional, List
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from modules.web_api.llm.core.calendar_utils import parse_relative_date_ru
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
from modules.web_api.llm.db.db_prompts import db_set_session_state
# ANCHOR[SNAPSHOT_HANDLER_DISPATCH]
def handle_snapshots_call(
fn_name: str,
fn_args: Dict[str, Any],
session_id: str,
user_message: str,
state_data: Dict[str, Any]
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
"""Обрабатывает запросы реестра и безопасного удаления срезов."""
# 1. Получение срезов
if fn_name == "db_get_snapshots":
date_param = fn_args.get("date_str")
if not date_param and user_message:
date_param = parse_relative_date_ru(user_message)
snapshots_res = get_snapshots_registry(date_str=date_param)
query_date = snapshots_res.get("query_date", "выбранную дату")
db_set_session_state(session_id, "SNAPSHOTS_VIEW", snapshots_res)
reply_text = f"Реестр срезов СКУД за {query_date}:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": snapshots_res
}
# 2. Удаление срезов (двухфазное подтверждение)
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
raw_ids = fn_args.get("snapshot_ids") or []
is_confirmed = fn_args.get("confirmed", False)
if raw_id and not raw_ids:
if isinstance(raw_id, str) and "," in raw_id:
raw_ids = [s.strip() for s in raw_id.split(",")]
else:
raw_ids = [raw_id]
safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
if not safe_ids:
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
if not is_confirmed:
query_date = state_data.get("query_date", "")
db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
"snapshot_ids": safe_ids,
"query_date": query_date,
"idle_turns": 0
})
ids_str = ", ".join(safe_ids)
reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOT_DELETE_CONFIRM",
"buttons": [
{"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
else:
delete_snapshots_safely(snapshot_ids=safe_ids)
query_date = state_data.get("query_date", "")
updated_data = get_snapshots_registry(date_str=query_date)
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_data
}
@@ -0,0 +1,97 @@
"""
===============================================================================
FILE: modules/ai_engine/handlers/task_handler.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine / handlers
ROLE: Изолированная обработка вызовов инструментов задач (get, edit, delete, export).
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_HANDLER_DISPATCH]: Обработка вызовов db_get_tasks и db_tasks_edit.
===============================================================================
"""
from typing import Dict, Any, Tuple, Optional
from services.tasks.service import get_tasks, execute_task_action
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
from modules.web_api.llm.db.db_prompts import db_set_session_state
# ANCHOR[TASK_HANDLER_DISPATCH]
def handle_tasks_call(
fn_name: str,
fn_args: Dict[str, Any],
user_id: int,
session_id: str
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
"""Обрабатывает нативные tool-вызовы по задачам."""
# 1. Просмотр задач
if fn_name == "db_get_tasks":
raw_tasks = get_tasks(user_id, status=fn_args.get("status"))
reply_text = "Вот интерактивный список ваших текущих задач:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
# 2. Модификация / Удаление / Экспорт
action = (fn_args.get("action") or "UPDATE").upper()
if fn_name == "db_add_task": action = "ADD"
elif fn_name == "db_delete_task": action = "DELETE"
elif fn_name == "db_update_task_status": action = "UPDATE"
# Двухфазное подтверждение удаления
if action == "DELETE":
task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_DELETE_CONFIRM",
"buttons": [
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
# Экспорт в Markdown
elif action == "EXPORT":
export_res = execute_task_action(
user_id=user_id,
action="EXPORT",
filename=fn_args.get("filename"),
status=fn_args.get("status")
)
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
action_payload = None
if export_res.get("status") == "success":
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": export_res.get("filename"),
"download_url": export_res.get("download_url"),
"tasks_count": export_res.get("tasks_count")
}
return reply_text, db_get_chat_history(session_id), action_payload
# Создание и обновление
else:
res = execute_task_action(
user_id=user_id,
action=action,
task_id=fn_args.get("task_id"),
title=fn_args.get("title"),
priority=fn_args.get("priority", "MEDIUM"),
status=fn_args.get("status"),
module=fn_args.get("module", "general"),
due_date=fn_args.get("due_date")
)
raw_tasks = get_tasks(user_id)
reply_text = res.get("message", "Операция над задачами выполнена.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
+192 -84
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,60 +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"
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.\n"
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
f"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
@@ -163,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]
@@ -198,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)
@@ -214,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"
@@ -236,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", "Отчет по задачам успешно экспортирован.")
@@ -274,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",
"buttons": [
{"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"},
{"label": "База знаний", "value": "покажи правила компании", "style": "secondary"}
]
}
return reply_text, db_get_chat_history(session_id), None
# 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
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:
rules_list = [str(rules_data)]
draft_lines.append(f" {s_id}.{i_id}. {txt}")
merged_prompt = "\n".join(draft_lines)
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",
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_rules_editor", "style": "primary"},
{"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"}
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "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", "выбранную дату")
@@ -370,13 +423,40 @@ def process_chat_message(
"data": updated_snapshots_res
}
# 6. Прочие сервисные инструменты
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
# Прямое выполнение удаления в 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:
@@ -388,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}")
+90 -239
View File
@@ -3,247 +3,42 @@
FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью.
ROLE: Детерминированный мгновенный перехват нажатий кнопок подтверждения
(без задержек LLM и обращения к Ollama).
AI-CONTEXT-ANCHORS:
- ANCHOR[FAST_PATH_MAIN]: Точка входа handle_fast_path_intercept.
===============================================================================
"""
import difflib
import json
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.tasks.service import get_tasks, delete_task
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.db.db_prompts import db_set_session_state, db_clear_session_state, db_get_tool_action
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)
# ANCHOR[FAST_PATH_MAIN]
def handle_fast_path_intercept(
session_id: str,
user_message: str,
full_user_content: str,
session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
msg_raw = user_message.strip()
msg_lower = msg_raw.lower()
# -------------------------------------------------------------------------
# 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ)
# -------------------------------------------------------------------------
if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]):
filename = "ROADMAP.md"
if " в " in msg_lower:
parts = msg_raw.split(" в ", 1)[1].strip().split()
if parts and ("." in parts[0] or parts[0].endswith("md")):
filename = parts[0]
res = execute_task_action(user_id=1, action="EXPORT", filename=filename)
reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.")
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": res.get("filename", filename),
"download_url": res.get("download_url", "#"),
"tasks_count": res.get("tasks_count", 0)
}
return reply, db_get_chat_history(session_id), action_payload
# -------------------------------------------------------------------------
# 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА)
# -------------------------------------------------------------------------
if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "):
target_snap_id = msg_raw.split()[-1].replace("#", "").strip()
from core.database import load_scud_from_db_by_snapshot
df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id)
if df_snap is None or df_snap.empty:
reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных."
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), None
total_cnt = len(df_snap)
present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0
absent_cnt = total_cnt - present_cnt
snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—'
log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—'
rows_list = []
for _, r in df_snap.iterrows():
rows_list.append({
"fio": r.get('Сотрудник', r.get('fio', '')),
"department": r.get('Подразделение', r.get('department_scud', '—')),
"time_in": r.get('Начало_дня', 'Нет входа'),
"first_activity": r.get('Первая_активность', '—'),
"time_out": r.get('Конец_дня', 'Нет выхода'),
"in_building": r.get('Находился_в_здании', '00:00'),
"is_present": bool(r.get('Пришел', False)),
"anomaly": r.get('anomaly_flag', 'NONE')
})
# Фиксация среза в памяти сессии для последующих вопросов к LLM
db_set_session_state(session_id, "SNAPSHOT_INSPECT", {
"snapshot_id": target_snap_id,
"log_date": log_date,
"snapshot_time": snap_time,
"records": rows_list,
"idle_turns": 0
})
reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})."
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "SNAPSHOT_INSPECT_CARD",
"snapshot_id": target_snap_id,
"log_date": log_date,
"snapshot_time": snap_time,
"total_count": total_cnt,
"present_count": present_cnt,
"absent_count": absent_cnt,
"records": rows_list
}
# -------------------------------------------------------------------------
# 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА
# -------------------------------------------------------------------------
if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]:
active_prompt = get_active_system_prompt()
state_data = session_state.get("data_json") if session_state else {}
draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": draft_text,
"action": "MANUAL_EDIT",
"idle_turns": 0
})
reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:"
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "PROMPT_EDITOR",
"raw_draft": draft_text,
"baseline_prompt": active_prompt
}
# -------------------------------------------------------------------------
# 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ
# -------------------------------------------------------------------------
if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]:
rules_data = get_rules()
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
db_set_session_state(session_id, "RULES_PREVIEW", {
"draft_text": raw_rules_text,
"action": "MANUAL_EDIT_RULES",
"idle_turns": 0
})
reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:"
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "RULES_EDITOR",
"raw_draft": raw_rules_text,
"baseline_prompt": raw_rules_text
}
# -------------------------------------------------------------------------
# 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ
# -------------------------------------------------------------------------
if msg_raw.startswith("action:save_draft_prompt:::"):
new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip()
active_prompt = get_active_system_prompt()
diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": new_draft_content,
"action": "MANUAL_EDIT",
"idle_turns": 0
})
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": new_draft_content,
"baseline_prompt": active_prompt,
"diff_html": diff_html,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
]
}
# -------------------------------------------------------------------------
# 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ
# -------------------------------------------------------------------------
if msg_raw.startswith("action:save_draft_rules:::"):
new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip()
rules_data = get_rules()
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content)
db_set_session_state(session_id, "RULES_PREVIEW", {
"draft_text": new_draft_content,
"action": "MANUAL_EDIT_RULES",
"idle_turns": 0
})
preview_reply = f"Предпросмотр изменений базы знаний компании:\n\n{diff_html}\n\nПодтвердите сохранение или отмените действие."
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": new_draft_content,
"baseline_prompt": baseline_rules,
"diff_html": diff_html,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"}
]
}
"""
Мгновенный перехват нажатий кнопок подтверждения (Fast-Path).
Возвращает (reply, history, action_type) или None, если требуется передать управление LLM.
"""
if not session_state:
return None
@@ -252,22 +47,38 @@ def handle_fast_path_intercept(
if not isinstance(state_data, dict):
state_data = {}
msg_lower = user_message.strip().lower()
# -------------------------------------------------------------------------
# 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА
# 1. ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ СИСТЕМНОГО ПРОМПТА
# -------------------------------------------------------------------------
if state_type == "PROMPT_PREVIEW":
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
if is_confirm:
action = state_data.get("action", "MANUAL_EDIT")
draft_text = state_data.get("draft_text", "")
if draft_text:
# Применение изменений
if action == "MANUAL_EDIT" and draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
else:
sec_id = state_data.get("section_id")
itm_id = state_data.get("item_id")
content = state_data.get("content", "")
if sec_id is not None and itm_id is not None:
apply_prompt_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
elif draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
# Закрытие сессии и зачистка
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
db_clear_session_state(session_id)
reply = "✅ Системный промпт успешно сохранен и применен в базе данных."
tool_action = db_get_tool_action("db_confirm_prompt_preview")
reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных."
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
@@ -276,40 +87,80 @@ def handle_fast_path_intercept(
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
db_clear_session_state(session_id)
reply = "❌ Изменения системного промпта отменены."
tool_action = db_get_tool_action("db_cancel_prompt_preview")
reply = tool_action.get("success_template", "❌ Изменения системного промпта отменены.") if tool_action else "❌ Изменения системного промпта отменены."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
# -------------------------------------------------------------------------
# 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ
# 2. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ СКУД
# -------------------------------------------------------------------------
elif state_type == "RULES_PREVIEW":
is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"]
elif state_type == "SNAPSHOT_DELETE_CONFIRM":
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)
snap_ids = state_data.get("snapshot_ids", [])
query_date = state_data.get("query_date", "")
close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS")
# Безопасное удаление через сервис
del_res = delete_snapshots_safely(snapshot_ids=snap_ids)
close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOTS_DELETED")
# Получаем свежий список за ту же дату
updated_data = get_snapshots_registry(date_str=query_date if query_date else None)
db_set_session_state(session_id, "SNAPSHOTS_VIEW", updated_data)
reply = f"✅ Успешно удалено снапшотов: {len(snap_ids)} шт."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_data
}
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOT_DELETE_CANCELLED")
db_clear_session_state(session_id)
reply = "✅ База знаний и правила компании успешно сохранены в базе данных."
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")
# -------------------------------------------------------------------------
# 3. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ
# -------------------------------------------------------------------------
elif state_type == "TASK_DELETE_CONFIRM":
is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
if is_confirm:
task_id = state_data.get("task_id")
delete_task(user_id=1, task_id=str(task_id))
close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETED")
db_clear_session_state(session_id)
reply = "❌ Изменение правил компании отменено."
raw_tasks = get_tasks(user_id=1)
reply = f"🗑 Задача #{task_id} удалена."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETE_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
+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"
+105 -3
View File
@@ -3,7 +3,11 @@
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Базовая санитарная очистка вывода и тегов инструментов.
ROLE: Семантический анализ намерений оператора (Intent Classifier) и
детерминированная сборка вызовов инструментов при сбоях нативного Function Calling.
AI-CONTEXT-ANCHORS:
- ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed.
===============================================================================
"""
@@ -28,9 +32,107 @@ def clean_output(text: str) -> str:
return text.strip() if text else ""
# ANCHOR[INTENT_INJECTOR_MAIN]
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Модель управляется через TOOLS_SCHEMA и системный контекст.
Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
Гибридный семантический анализатор:
Если модель ответила текстом с сомнениями или пропустила tool_call,
распознает доменное намерение и конструирует синтетический tool_call.
"""
if existing_tool_calls:
return existing_tool_calls
msg_clean = user_message.strip().lower()
# 1. СЕМАНТИКА: Просмотр системного промпта
# Паттерны: "покажи системный промпт", "выведи промпт", "текущий промпт", "какой сейчас системный промпт"
if "промпт" in msg_clean and any(kw in msg_clean for kw in ["покажи", "выведи", "какой", "дай", "текст", "актуальн"]):
logger.info("[IntentInjector] Распознано намерение просмотра системного промпта")
return [{
"function": {
"name": "db_get_system_prompt",
"arguments": {}
}
}]
# 2. СЕМАНТИКА: Удаление задач
# Паттерны: "удали задачу 37", "убери 37 задачу", "сотри таску #37", "сними с повестки задачу 37"
if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "сотри", "сними"]) and any(kw in msg_clean for kw in ["задач", "таск"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение удаления задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "DELETE", "task_id": task_id}
}
}]
# 3. СЕМАНТИКА: Управление системным промптом (удаление и мульти-удаление)
# Паттерны: "удали 1.8 и 3.4", "удали пункт 2.3", "вычеркни 1.8, 3.4 из промпта"
if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "вычеркни", "сотри"]) and not any(kw in msg_clean for kw in ["задач", "снапшот", "срез"]):
node_matches = re.findall(r'(\d+)[\.\s]+(\d+)', user_message)
if node_matches:
formatted_nodes = [f"{s}.{i}" for s, i in node_matches]
logger.info(f"[IntentInjector] Распознано намерение удаления узлов промпта: {formatted_nodes}")
return [{
"function": {
"name": "db_prompt_node_edit",
"arguments": {
"action": "BATCH_DELETE" if len(formatted_nodes) > 1 else "DELETE",
"section_id": int(node_matches[0][0]),
"item_id": int(node_matches[0][1]),
"nodes_list": formatted_nodes,
"content": ""
}
}
}]
# 4. СЕМАНТИКА: Добавление пункта промпта
# Паттерны: "добавь 3.4 Текст", "добавь пункт 3.4 Текст", "впиши в 3.4 Текст"
if any(kw in msg_clean for kw in ["добавь", "добавить", "впиши", "запиши"]) and not any(kw in msg_clean for kw in ["задач", "таск"]):
add_match = re.search(r'(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)', user_message)
if add_match:
sec_id = int(add_match.group(1))
itm_id = int(add_match.group(2))
content = add_match.group(3).strip()
logger.info(f"[IntentInjector] Распознано намерение добавления узла промпта: {sec_id}.{itm_id}")
return [{
"function": {
"name": "db_prompt_node_edit",
"arguments": {
"action": "ADD",
"section_id": sec_id,
"item_id": itm_id,
"content": content
}
}
}]
# 5. СЕМАНТИКА: Смена статуса задач
if any(kw in msg_clean for kw in ["в работу", "начни", "стартуй", "за работу"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение взятия в работу задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "UPDATE", "task_id": task_id, "status": "IN_PROGRESS"}
}
}]
if any(kw in msg_clean for kw in ["заверши", "закрой", "готово", "выполнено"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение закрытия задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "UPDATE", "task_id": task_id, "status": "COMPLETED"}
}
}]
return existing_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
+9 -40
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:
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, is_ephemeral, created_at
SELECT role, content
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)]
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
-3
View File
@@ -22,9 +22,6 @@ from services.tasks.service import (
delete_task as db_delete_task,
execute_task_action as db_tasks_edit
)
# Защитный алиас для обратной совместимости
db_update_task_status = db_update_task_details
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
from services.tasks.repository import normalize_task_id
+5 -17
View File
@@ -69,18 +69,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": "Опциональный фильтр статуса задач"
}
}
}
@@ -146,11 +142,7 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_get_snapshots",
"description": (
"Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
"ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
"КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
),
"description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
"parameters": {
"type": "object",
"properties": {
@@ -166,21 +158,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": "Флаг окончательного подтверждения удаления пользователем"
}
}
}
-16
View File
@@ -26,17 +26,12 @@ 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
# ANCHOR[APP_CONFIG]
logging.basicConfig(
@@ -66,11 +61,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)
# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
@@ -90,12 +80,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):
+2 -12
View File
@@ -72,7 +72,7 @@ def login(req: AuthRequest):
username = req.username.strip().lower()
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,))
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
@@ -82,17 +82,7 @@ def login(req: AuthRequest):
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
# Возвращаем full_name (если не задано — отдаем username)
full_name = user["full_name"] if user["full_name"] else user["username"]
return {
"status": "success",
"token": token,
"username": user["username"],
"full_name": full_name,
"user_id": user["id"],
"is_admin": is_admin
}
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@router.post("/change-password")
+75 -136
View File
@@ -1,154 +1,93 @@
"""
===============================================================================
FILE: modules/web_api/routers/chat.py
ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов,
поддержкой Function Calling, Fast-Path и оптического распознавания OCR.
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers
ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков.
===============================================================================
"""
import os
import shutil
import base64
import logging
from typing import Optional, List, Dict, Any
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 llm.agent import process_chat_message
from config import BASE_DIR
from .auth import get_current_user
from modules.ai_engine.agent import process_chat_message
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)
elif token.startswith("dev_token_"):
try:
return int(token.replace("dev_token_", ""))
except ValueError:
pass
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="Пустое сообщение")
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
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)
"""Диалог авторизованного пользователя с агентом."""
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)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
file_context = ""
image_b64 = None
fn_lower = file.filename.lower()
# 1. Текстовые форматы
if fn_lower.endswith((".txt", ".csv", ".log", ".md")):
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
file_context = f.read(6000)
except Exception as e:
logger.warning(f"Не удалось прочитать текст: {e}")
# 2. Изображения (прямой OCR)
elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")):
try:
with open(file_path, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
logger.warning(f"Ошибка кодирования картинки в base64: {e}")
# 3. PDF документы (текстовый слой + рендеринг скана при необходимости)
elif fn_lower.endswith(".pdf"):
# Попытка извлечь встроенный текстовый слой
try:
import pypdf
reader = pypdf.PdfReader(file_path)
extracted = []
for page in reader.pages:
t = page.extract_text()
if t:
extracted.append(t)
file_context = "\n".join(extracted).strip()
except Exception:
pass
# Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR
if len(file_context) < 40:
try:
import fitz # PyMuPDF
doc = fitz.open(file_path)
if len(doc) > 0:
page = doc[0]
pix = page.get_pixmap(dpi=150)
img_bytes = pix.tobytes("png")
image_b64 = base64.b64encode(img_bytes).decode("utf-8")
file_context = ""
except Exception as e:
logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}")
user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}"
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
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": "Контекст сессии полностью очищен"}
-31
View File
@@ -1,31 +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("/")
def api_get_exceptions():
return get_all_exceptions_from_db()
@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("/")
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"}
@@ -1,71 +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"}
-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}
-53
View File
@@ -1,53 +0,0 @@
"""
===============================================================================
FILE: modules/web_api/routers/snapshots.py
ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД.
===============================================================================
"""
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 services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from services.scud_export import run_export
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
class CreateSnapshotRequest(BaseModel):
date_str: Optional[str] = None
class DeleteSnapshotsRequest(BaseModel):
snapshot_ids: List[str]
@router.get("")
def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)):
return get_snapshots_registry(date_str=date_str)
@router.post("/create")
def api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)):
"""Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite."""
try:
success = run_export(input_date=req.date_str, save_xlsx=True, debug=False)
if not success:
raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион")
fresh_data = get_snapshots_registry(date_str=req.date_str)
return {"status": "success", "message": "Срез успешно создан", "data": fresh_data}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}")
@router.delete("")
def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)):
safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")]
if not safe_ids:
raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления")
res = delete_snapshots_safely(snapshot_ids=safe_ids)
return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))}
+128 -137
View File
@@ -1,174 +1,165 @@
<!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>
<title>SCUD Orion AI — Управление и Аналитика</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome Icons -->
<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;
}
/* Шрифт сообщений чата */
#chat-messages-container .message-content,
#chat-messages-container .text-xs,
#chat-messages-container .text-sm {
font-size: 14.5px !important;
line-height: 1.6 !important;
}
#chat-messages-container pre,
#chat-messages-container code {
font-size: 13.5px !important;
}
.user-chat-bubble {
scroll-margin-top: 24px !important;
}
</style>
<link rel="stylesheet" href="/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 font-sans h-screen flex overflow-hidden text-slate-800">
<div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">
<!-- ЛЕВАЯ КОЛОНКА (ДИНАМИЧЕСКИЙ САЙДБАР) -->
<aside class="w-80 md:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
<!-- ДИНАМИЧЕСКИЙ ТАБ-БАР ХАБОВ И ПОДВКЛАДОК -->
<div id="sidebar-dynamic-header" class="shrink-0 bg-slate-50 border-b border-slate-200"></div>
<!-- ДИНАМИЧЕСКИЙ КОНТЕНТНЫЙ СЛОТ -->
<div id="sidebar-dynamic-content" class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...
<!-- Боковая панель (Задачи и Навигация) -->
<aside id="task-drawer" class="w-80 sm:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full z-20 shadow-sm transition-all duration-300">
<!-- Шапка панели задач -->
<div class="p-4 border-b border-slate-200 flex items-center justify-between bg-slate-50/70">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white shadow-sm">
<i class="fa-solid fa-list-check text-sm"></i>
</div>
<div>
<h2 class="font-bold text-sm text-slate-900 leading-tight">Бэклог задач</h2>
<p class="text-[11px] text-slate-500">SCUD Orion AI Roadmap</p>
</div>
</div>
</div>
<!-- ПОДВАЛ САЙДБАРА: ПРОФИЛЬ, НАСТРОЙКИ, АДМИНКА И ВЫХОД -->
<div class="p-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
<i class="fa-solid fa-user"></i>
</div>
<div class="min-w-0 flex flex-col">
<span id="user-display-name" class="text-xs font-bold text-slate-800 truncate">Пользователь</span>
<span id="user-display-role" class="text-[10px] text-slate-400">Оператор</span>
</div>
</div>
<div class="flex items-center gap-1">
<button id="admin-panel-btn" type="button" onclick="openAdminModal()" class="hidden p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-slate-200 rounded-lg transition" title="Управление пользователями">
<i class="fa-solid fa-users-gear text-sm"></i>
</button>
<button type="button" onclick="openProfileModal()" class="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200 rounded-lg transition" title="Сменить пароль">
<i class="fa-solid fa-gear text-sm"></i>
</button>
<button type="button" onclick="AuthManager.logout()" class="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition" title="Выйти из системы">
<i class="fa-solid fa-arrow-right-from-bracket text-sm"></i>
<button type="button" onclick="openAddTaskModal()" title="Добавить задачу"
class="px-2.5 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 rounded-lg text-xs font-semibold flex items-center gap-1 transition cursor-pointer">
<i class="fa-solid fa-plus"></i>
<span>Задача</span>
</button>
</div>
<!-- Фильтры задач -->
<div class="px-4 py-2.5 border-b border-slate-100 flex items-center justify-between gap-1 text-xs">
<button onclick="filterTasksByTab('IN_PROGRESS')" id="tab-in-progress" class="task-tab-btn font-semibold px-2.5 py-1 rounded-md text-indigo-600 bg-indigo-50 transition">В работе</button>
<button onclick="filterTasksByTab('BACKLOG')" id="tab-backlog" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">В планах</button>
<button onclick="filterTasksByTab('COMPLETED')" id="tab-completed" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">Готово</button>
<button onclick="filterTasksByTab('ALL')" id="tab-all" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">Все</button>
</div>
<!-- Список задач с прокруткой (ID исправлен на tasks-list) -->
<div id="tasks-list" class="flex-1 overflow-y-auto p-3 space-y-2.5">
<div class="text-center py-8 text-xs text-slate-400">Загрузка задач...</div>
</div>
<!-- Подвал панели пользователя -->
<div class="p-3 border-t border-slate-200 bg-slate-50/50 flex items-center justify-between text-xs">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-slate-300 flex items-center justify-center text-slate-700 font-bold">
<i class="fa-solid fa-user text-xs"></i>
</div>
<div class="truncate">
<span id="current-username" class="font-semibold text-slate-900 block truncate">Александр Пушков</span>
<span id="user-role-badge" class="text-[10px] text-indigo-600 font-medium">Администратор</span>
</div>
</div>
<button onclick="logout()" title="Выйти" class="p-1.5 text-slate-400 hover:text-rose-600 transition">
<i class="fa-solid fa-arrow-right-from-bracket"></i>
</button>
</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>
<!-- Основная рабочая область чата -->
<main class="flex-1 flex flex-col min-w-0 bg-white relative h-full overflow-hidden">
<!-- Верхний заголовок чата -->
<header class="h-14 border-b border-slate-200 px-4 flex items-center justify-between bg-white shrink-0 z-10">
<div class="flex items-center gap-3">
<button type="button" onclick="toggleTaskDrawer()" class="p-1.5 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-lg transition" title="Переключить боковую панель">
<i class="fa-solid fa-bars"></i>
</button>
<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>
<h1 class="font-bold text-sm text-slate-900 flex items-center gap-2">
<span>SCUD Orion AI Assistant</span>
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-100 text-emerald-800">Online</span>
</h1>
<p class="text-[11px] text-slate-500">Система интеллектуального аудита и контроля СКУД / 1С</p>
</div>
<p class="text-[10px] text-slate-400">Система интеллектуального аудита и контроля СКУД / 1С</p>
</div>
<div class="flex items-center gap-2 text-xs">
<button type="button" onclick="handleActionButtonClick('покажи системный промпт')" class="px-2.5 py-1 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-md border border-slate-200 transition">
<i class="fa-solid fa-terminal mr-1"></i> Промпт
</button>
<button type="button" onclick="handleActionButtonClick('покажи снапшоты')" class="px-2.5 py-1 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-md border border-slate-200 transition">
<i class="fa-solid fa-camera mr-1"></i> Срезы СКУД
</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-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-xs"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-xs text-slate-700 leading-relaxed">
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
</div>
<!-- Drop Overlay при перетаскивании файлов -->
<div id="drop-overlay" class="hidden absolute inset-0 bg-indigo-600/10 backdrop-blur-[2px] border-2 border-dashed border-indigo-500 rounded-2xl m-4 z-50 items-center justify-center flex-col gap-2 pointer-events-none">
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
<p class="text-xs font-bold text-indigo-900">Перетащите файл сюда для отправки в диалог</p>
</div>
<!-- Окно сообщений с центрированием контента -->
<div id="chat-window" class="flex-1 p-4 overflow-y-auto bg-slate-50/50 scroll-smooth">
<!-- Центрирующая колонка для сообщений -->
<div class="max-w-4xl w-full mx-auto space-y-4">
<!-- Стартовое приветственное сообщение -->
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент SCUD Orion AI
</p>
<div class="text-slate-800 text-xs sm:text-sm leading-relaxed">
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
</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">
<!-- Невидимая распорка в самом низу для свободного скролла любого вопроса наверх -->
<div id="chat-bottom-spacer" class="min-h-[85vh] pointer-events-none w-full"></div>
</div>
</div>
<!-- Центрированная нижняя панель ввода -->
<div class="p-3 bg-white border-t border-slate-200 shrink-0 z-10">
<div class="max-w-4xl w-full mx-auto">
<!-- Блок предпросмотра прикрепленного файла -->
<div id="file-preview-container" class="hidden mb-2 p-2 bg-indigo-50 border border-indigo-200 rounded-xl flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<i class="fa-solid fa-file-arrow-up text-indigo-600 text-sm"></i>
<span id="file-name-display" class="text-xs font-semibold text-slate-800 truncate"></span>
<span id="file-size-display" class="text-[10px] text-slate-500"></span>
</div>
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-rose-600 transition p-1">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Форма отправки -->
<form id="chat-form" onsubmit="sendMessage(event)" class="flex items-end gap-2">
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" />
<button type="button" onclick="document.getElementById('file-input').click()"
title="Прикрепить файл"
class="p-2.5 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-xl transition shrink-0 cursor-pointer">
<i class="fa-solid fa-paperclip text-sm"></i>
</button>
<div class="flex-1 bg-slate-100 border border-slate-300 focus-within:border-indigo-600 focus-within:bg-white rounded-2xl p-1.5 transition flex items-center shadow-inner">
<textarea id="user-input" rows="1"
placeholder="Команда, вопрос или перетащите файл сюда..."
class="w-full bg-transparent px-2 text-xs sm:text-sm text-slate-800 focus:outline-none resize-none overflow-y-auto leading-relaxed"
style="height: 24px; max-height: 120px;"></textarea>
</div>
<!-- СТРОКА ВВОДА -->
<div class="px-4 py-2 bg-white border-t border-slate-200">
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0" title="Прикрепить файл">
<i class="fa-solid fa-paperclip text-xs"></i>
</button>
<input type="file" id="file-upload-input" class="hidden" />
<textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..."
class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>
<button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
<i class="fa-solid fa-paper-plane text-[10px]"></i>
<button type="submit" id="send-btn"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white p-2.5 rounded-xl transition shrink-0 shadow-sm cursor-pointer">
<i class="fa-solid fa-paper-plane text-sm"></i>
</button>
</form>
</div>
</div>
</main>
</div>
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
<div id="modals-container"></div>
<!-- ГЛОБАЛЬНАЯ DRAG-AND-DROP ЗОНА -->
<div id="global-drag-overlay"
class="fixed inset-0 bg-indigo-900/40 backdrop-blur-xs z-50 hidden flex items-center justify-center pointer-events-none transition-all duration-200">
<div class="bg-white border-2 border-dashed border-indigo-500 rounded-3xl p-10 flex flex-col items-center gap-3 shadow-2xl scale-100 transition-transform">
<div class="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center text-3xl shadow-inner">
<i class="fa-solid fa-cloud-arrow-up animate-bounce"></i>
</div>
<div class="text-base font-bold text-slate-800">Перетащите файл в любую точку окна</div>
<div class="text-xs text-slate-500 font-medium">PDF-документы, сканы, отчеты или изображения</div>
</div>
</div>
<!-- СКРИПТЫ -->
<script src="/static/js/auth.js?v=2.5.8"></script>
<script src="/static/js/tasks.js?v=2.5.8"></script>
<script src="/static/js/manual_absences.js?v=2.5.8"></script>
<script src="/static/js/sidebar.js?v=2.5.8"></script>
<script src="/static/js/chat/task_widget.js?v=2.5.8"></script>
<script src="/static/js/chat/core.js?v=2.5.8"></script>
<script src="/static/js/app.js?v=2.5.8"></script>
<!-- Скрипты клиентской логики -->
<script src="/js/auth.js"></script>
<script src="/js/tasks.js"></script>
<script src="/js/chat/task_widget.js"></script>
<script src="/js/chat/core.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
+3 -366
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,360 +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'
];
const container = document.getElementById('modals-container');
if (!container) return;
for (const file of modalFiles) {
try {
const res = await fetch(`/static/modals/${file}?v=2.5.8`);
if (res.ok) {
const html = await res.text();
container.insertAdjacentHTML('beforeend', html);
}
} catch (e) {
console.error(`[Modals] Ошибка загрузки ${file}:`, e);
}
}
}
// ============================================================================
// 2. УПРАВЛЕНИЕ АВТОРИЗАЦИЕЙ И ПРОФИЛЕМ В UI
// ============================================================================
function showAuthModal() {
const modal = document.getElementById("auth-modal");
if (modal) modal.classList.remove("hidden");
}
function hideAuthModal() {
const modal = document.getElementById("auth-modal");
if (modal) modal.classList.add("hidden");
}
function updateUIState() {
const nameEl = document.getElementById("user-display-name");
const roleEl = document.getElementById("user-display-role");
const adminBtn = document.getElementById("admin-panel-btn");
if (nameEl) nameEl.innerText = AuthManager.getFullName();
if (roleEl) roleEl.innerText = AuthManager.isAdmin() ? "Администратор" : "Оператор";
if (adminBtn) {
if (AuthManager.isAdmin()) {
adminBtn.classList.remove("hidden");
} else {
adminBtn.classList.add("hidden");
}
}
}
async function handleLoginSubmit(e) {
e.preventDefault();
const uInput = document.getElementById("auth-username");
const pInput = document.getElementById("auth-password");
const errEl = document.getElementById("auth-error");
if (errEl) errEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: uInput.value.trim(),
password: pInput.value
})
});
if (res.ok) {
const data = await res.json();
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
hideAuthModal();
updateUIState();
if (window.SidebarManager) SidebarManager.setHub('TASKS');
} else {
const err = await res.json();
if (errEl) {
errEl.innerText = err.detail || "Неверный логин или пароль";
errEl.classList.remove("hidden");
}
}
} catch (err) {
if (errEl) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
}
function openProfileModal() {
const modal = document.getElementById("profile-modal");
if (modal) modal.classList.remove("hidden");
}
function closeProfileModal() {
const modal = document.getElementById("profile-modal");
if (modal) modal.classList.add("hidden");
}
async function handleChangePassword(e) {
e.preventDefault();
const oldP = document.getElementById("old-pass").value;
const newP = document.getElementById("new-pass").value;
const errEl = document.getElementById("pass-error");
if (errEl) errEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ old_password: oldP, new_password: newP })
});
if (res.ok) {
alert("Пароль успешно изменен");
closeProfileModal();
} else {
const err = await res.json();
if (errEl) {
errEl.innerText = err.detail || "Ошибка изменения пароля";
errEl.classList.remove("hidden");
}
}
} catch (e) {
if (errEl) {
errEl.innerText = "Ошибка сети";
errEl.classList.remove("hidden");
}
}
}
// ============================================================================
// 3. АДМИНИСТРИРОВАНИЕ ПОЛЬЗОВАТЕЛЕЙ
// ============================================================================
function openAdminModal() {
const modal = document.getElementById("admin-modal");
if (modal) {
modal.classList.remove("hidden");
loadAdminUsers();
}
}
function closeAdminModal() {
const modal = document.getElementById("admin-modal");
if (modal) modal.classList.add("hidden");
}
async function loadAdminUsers() {
const listEl = document.getElementById("admin-users-list");
if (!listEl) return;
listEl.innerHTML = `<div class="text-center py-4 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>`;
try {
const res = await fetch("/api/v1/admin/users", { headers: AuthManager.getAuthHeaders() });
if (res.ok) {
const users = await res.json();
listEl.innerHTML = users.map(u => `
<div class="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs">
<div>
<div class="font-bold text-slate-800">${escapeHtml(u.full_name || u.username)} <span class="text-slate-400 font-mono text-[10px]">(${escapeHtml(u.username)})</span></div>
<div class="text-[10px] ${u.is_admin ? 'text-indigo-600 font-bold' : 'text-slate-400'}">${u.is_admin ? 'Администратор' : 'Оператор'}</div>
</div>
<button onclick="deleteAdminUser(${u.id}, '${escapeHtml(u.username)}')" class="text-slate-400 hover:text-rose-600 p-1.5" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
`).join('');
} else {
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка загрузки пользователей</div>`;
}
} catch (e) {
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка сети</div>`;
}
}
async function handleCreateUser(e) {
e.preventDefault();
const u = document.getElementById("new-user-username").value;
const p = document.getElementById("new-user-password").value;
const f = document.getElementById("new-user-fullname").value;
const a = document.getElementById("new-user-admin").checked;
try {
const res = await fetch("/api/v1/admin/users", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ username: u, password: p, full_name: f, is_admin: a })
});
if (res.ok) {
document.getElementById("new-user-username").value = "";
document.getElementById("new-user-password").value = "";
document.getElementById("new-user-fullname").value = "";
document.getElementById("new-user-admin").checked = false;
loadAdminUsers();
} else {
const err = await res.json();
alert(err.detail || "Ошибка создания пользователя");
}
} catch (e) {
alert("Ошибка сети");
}
}
async function deleteAdminUser(id, username) {
if (!confirm(`Удалить пользователя ${username}?`)) return;
try {
const res = await fetch(`/api/v1/admin/users/${id}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) {
loadAdminUsers();
} else {
const err = await res.json();
alert(err.detail || "Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
}
// ============================================================================
// 4. ВСПОМОГАТЕЛЬНЫЕ ФОРМАТЕРЫ И МОДАЛКА УДАЛЕНЩИКОВ
// ============================================================================
function dmyToYmd(str) {
if (!str) return "";
const parts = str.replace(/_/g, '.').split('.');
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
return "";
}
function ymdToDmy(str) {
if (!str) return "";
const parts = str.split('-');
if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`;
return "";
}
function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom = '', dateTo = '') {
const modal = document.getElementById("remote-worker-modal");
const titleEl = document.getElementById("remote-modal-title");
const modeInput = document.getElementById("rw-mode");
const fioInput = document.getElementById("rw-fio");
const deptInput = document.getElementById("rw-dept");
const fromInput = document.getElementById("rw-date-from");
const toInput = document.getElementById("rw-date-to");
const errEl = document.getElementById("rw-error");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (modeInput) modeInput.value = mode;
if (mode === 'EDIT') {
if (titleEl) titleEl.innerHTML = `<i class="fa-solid fa-pen-to-square text-emerald-600"></i><span>Изменение сроков удаленки</span>`;
if (fioInput) {
fioInput.value = fio;
fioInput.readOnly = true;
fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
}
if (deptInput) {
deptInput.value = dept || "Все";
deptInput.readOnly = true;
deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
}
if (fromInput) fromInput.value = dmyToYmd(dateFrom);
if (toInput) toInput.value = dmyToYmd(dateTo);
} else {
if (titleEl) titleEl.innerHTML = `<i class="fa-solid fa-house-laptop text-emerald-600"></i><span>Добавление удаленщика</span>`;
if (fioInput) {
fioInput.value = "";
fioInput.readOnly = false;
fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
}
if (deptInput) {
deptInput.value = "Все";
deptInput.readOnly = false;
deptInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
}
const today = new Date().toISOString().split('T')[0];
if (fromInput) fromInput.value = today;
if (toInput) toInput.value = "";
}
modal.classList.remove("hidden");
}
function closeRemoteWorkerModal() {
const modal = document.getElementById("remote-worker-modal");
if (modal) modal.classList.add("hidden");
}
async function handleRemoteWorkerSubmit(e) {
e.preventDefault();
const mode = document.getElementById("rw-mode").value;
const fio = document.getElementById("rw-fio").value.trim();
const dept = document.getElementById("rw-dept").value.trim() || "Все";
const fromVal = ymdToDmy(document.getElementById("rw-date-from").value);
const toVal = ymdToDmy(document.getElementById("rw-date-to").value);
const errEl = document.getElementById("rw-error");
if (errEl) errEl.classList.add("hidden");
const method = (mode === 'EDIT') ? "PUT" : "POST";
const payload = (mode === 'EDIT')
? { fio: fio, date_from: fromVal, date_to: toVal }
: { fio: fio, department: dept, reason: "Удаленная работа", date_from: fromVal, date_to: toVal };
try {
const res = await fetch("/api/v1/remote-workers", {
method: method,
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify(payload)
});
if (res.ok) {
closeRemoteWorkerModal();
if (window.SidebarManager) SidebarManager.renderContent();
} else {
const err = await res.json();
if (errEl) {
errEl.innerText = err.detail || "Ошибка сохранения";
errEl.classList.remove("hidden");
}
}
} catch (err) {
if (errEl) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
}
// ============================================================================
// 5. ИНИЦИАЛИЗАЦИЯ ПРИЛОЖЕНИЯ
// ============================================================================
document.addEventListener("DOMContentLoaded", async () => {
// 1. Асинхронная подгрузка модальных окон
await loadModals();
// 2. Инициализация автокомплита исключений после монтирования разметки
const excInput = document.getElementById("exception-value-input");
const excBox = document.getElementById("exception-suggestions");
if (excInput && excBox && typeof setupStaffAutocomplete === "function") {
setupStaffAutocomplete(excInput, "exception-suggestions");
}
// 3. Авто-высота поля ввода команд
document.addEventListener("DOMContentLoaded", () => {
const userInputEl = document.getElementById("user-input");
if (userInputEl) {
userInputEl.addEventListener("input", function() {
this.style.height = "24px";
@@ -380,19 +21,15 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
// 4. Проверка сессии пользователя
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();
}
+297 -64
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();
getUserId() {
const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
return uid ? parseInt(uid, 10) : 1;
},
const usernameInput = document.getElementById("auth-username-input");
const passwordInput = document.getElementById("auth-password-input");
const errorEl = document.getElementById("auth-error");
getUsername() {
return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
},
if (!usernameInput || !passwordInput) return;
getFullName() {
return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
},
const username = usernameInput.value.trim();
const password = passwordInput.value;
isAdmin() {
return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
},
if (!username || !password) return;
isAuthenticated() {
return Boolean(this.getToken());
},
if (errorEl) errorEl.classList.add("hidden");
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,231 +0,0 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/manual_absences.js
* ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП
* и мгновенная синхронизация с боковой панелью SidebarManager.
* ===============================================================================
*/
let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER'
let reasonsCache = [];
async function loadAbsenceReasons() {
try {
const res = await fetch('/api/v1/manual-absences/reasons');
if (res.ok) {
const data = await res.json();
reasonsCache = data.reasons || [];
}
} catch (e) {
console.error('Ошибка загрузки причин:', e);
}
}
function openManualAbsenceModal(type) {
activeAbsenceType = type;
const isTrip = type === 'LOCAL_TRIP';
const titleEl = document.getElementById('manual-absence-modal-title');
const reasonBlock = document.getElementById('manual-absence-reason-block');
const reasonSelect = document.getElementById('manual-absence-reason-select');
if (titleEl) {
titleEl.innerHTML = isTrip
? '<i class="fa-solid fa-location-dot text-indigo-600 mr-2"></i>Местная командировка'
: '<i class="fa-solid fa-clipboard-list text-purple-600 mr-2"></i>Иные причины отсутствия';
}
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 fioInput = document.getElementById('manual-absence-fio-input');
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
const startDateInput = document.getElementById('manual-absence-start-date');
const endDateInput = document.getElementById('manual-absence-end-date');
const suggestionsBox = document.getElementById('manual-absence-suggestions');
if (fioInput) fioInput.value = '';
if (deptInput) deptInput.value = '';
if (posInput) posInput.value = '';
if (startDateInput) startDateInput.value = '';
if (suggestionsBox) {
suggestionsBox.classList.add('hidden');
suggestionsBox.innerHTML = '';
}
// Окончание по умолчанию — сегодняшний день
if (endDateInput) {
const today = new Date().toISOString().split('T')[0];
endDateInput.value = today;
}
loadManualAbsencesTable();
const modal = document.getElementById('manual-absence-modal');
if (modal) modal.classList.remove('hidden');
}
function closeManualAbsenceModal() {
const modal = document.getElementById('manual-absence-modal');
if (modal) modal.classList.add('hidden');
}
// Живой автокомплит ФИО из базы zup_staff
let searchTimeout = null;
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
const box = document.getElementById(suggestionsBoxId);
if (!inputEl || !box) return;
inputEl.addEventListener('input', function() {
const val = this.value.trim();
clearTimeout(searchTimeout);
if (val.length < 2) {
box.classList.add('hidden');
box.innerHTML = '';
return;
}
searchTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
if (!res.ok) return;
const items = await res.json();
if (items.length === 0) {
box.classList.add('hidden');
return;
}
box.innerHTML = items.map(it => `
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
onclick="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}')">
<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);
});
}
function selectStaffSuggestion(fio, dept, pos) {
const fioInput = document.getElementById('manual-absence-fio-input');
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
const box = document.getElementById('manual-absence-suggestions');
if (fioInput) fioInput.value = fio;
if (deptInput) deptInput.value = dept;
if (posInput) posInput.value = pos;
if (box) box.classList.add('hidden');
}
async function submitManualAbsence() {
const fioInput = document.getElementById('manual-absence-fio-input');
const fio = fioInput ? fioInput.value.trim() : '';
if (!fio) {
alert('Укажите ФИО сотрудника');
return;
}
const deptVal = document.getElementById('manual-absence-dept')?.value.trim() || '';
const posVal = document.getElementById('manual-absence-pos')?.value.trim() || '';
const startDateVal = document.getElementById('manual-absence-start-date')?.value || null;
const endDateVal = document.getElementById('manual-absence-end-date')?.value || null;
const reasonSelect = document.getElementById('manual-absence-reason-select');
const payload = {
absence_type: activeAbsenceType,
fio: fio,
department: deptVal,
position: posVal,
date_start: startDateVal,
date_end: endDateVal,
reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
};
try {
const res = await fetch('/api/v1/manual-absences/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
if (fioInput) fioInput.value = '';
loadManualAbsencesTable();
// Обновляем список карточек в боковой панели и закрываем окно
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
SidebarManager.renderContent();
}
closeManualAbsenceModal();
} else {
alert('Ошибка добавления записи');
}
} catch (e) {
alert('Сетевая ошибка при добавлении');
}
}
async function loadManualAbsencesTable() {
const tableContainer = document.getElementById('manual-absences-table-body');
if (!tableContainer) return;
try {
const res = await fetch(`/api/v1/manual-absences/?type=${activeAbsenceType}`);
if (!res.ok) return;
const data = await res.json();
const items = data.items || [];
if (items.length === 0) {
tableContainer.innerHTML = '<tr><td colspan="5" class="text-center p-4 text-xs text-slate-400">Нет активных записей</td></tr>';
return;
}
tableContainer.innerHTML = items.map(it => `
<tr class="border-b border-slate-100 text-xs hover:bg-slate-50">
<td class="p-2 font-bold text-slate-800">${escapeHtml(it.fio)}</td>
<td class="p-2 text-slate-500">${escapeHtml(it.department || '—')}</td>
<td class="p-2 text-slate-600">${escapeHtml(it.reason)}</td>
<td class="p-2 text-center text-slate-500 font-mono text-[11px]">${it.date_start || '—'} / ${it.date_end || '—'}</td>
<td class="p-2 text-center">
<button onclick="deleteManualAbsenceRecord(${it.id})" class="text-slate-400 hover:text-rose-600 transition p-1" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</td>
</tr>
`).join('');
} catch (e) {
console.error(e);
}
}
async function deleteManualAbsenceRecord(id) {
if (!confirm('Удалить эту запись?')) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: 'DELETE' });
if (res.ok) {
loadManualAbsencesTable();
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
SidebarManager.renderContent();
}
}
} catch (e) {
alert('Ошибка при удалении');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadAbsenceReasons();
setupStaffAutocomplete(
document.getElementById('manual-absence-fio-input'),
'manual-absence-suggestions'
);
});
-673
View File
@@ -1,673 +0,0 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/sidebar.js
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2),
* модальным окном добавления исключений с автокомплитом из 1С:ЗУП.
* ===============================================================================
*/
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
window.SidebarManager = {
currentHub: 'TASKS',
currentSubTab: {
'REGISTRIES': 'REMOTE'
},
hubs: [
{ id: 'TASKS', label: 'Задачи', icon: 'fa-list-check' },
{ id: 'SNAPSHOTS', label: 'Срезы', icon: 'fa-camera' },
{ id: 'REGISTRIES', label: 'Реестры', icon: 'fa-address-book' },
{ id: 'PROMPT', label: 'Промпт', icon: 'fa-terminal' },
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
],
// Сетка реестров 2x2
subTabs: {
'REGISTRIES': [
{ id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' },
{ id: 'LOCAL_TRIP', label: 'Мест. командир.', icon: 'fa-location-dot' },
{ id: 'OTHER', label: 'Иное', icon: 'fa-clipboard-list' }
]
},
init() {
this.renderHeader();
this.renderContent();
},
setHub(hubId) {
this.currentHub = hubId;
this.renderHeader();
this.renderContent();
},
setSubTab(subTabId) {
this.currentSubTab[this.currentHub] = subTabId;
this.renderHeader();
this.renderContent();
},
renderHeader() {
const headerContainer = document.getElementById("sidebar-dynamic-header");
if (!headerContainer) return;
// 1. Основные 5 Хабов
const hubsHtml = `
<div class="flex items-center border-b border-slate-200 bg-slate-50/80 px-1 pt-1.5 overflow-x-auto gap-0.5">
${this.hubs.map(h => {
const isActive = this.currentHub === h.id;
return `
<button onclick="SidebarManager.setHub('${h.id}')"
class="flex-1 py-1.5 px-1 flex flex-col items-center gap-1 border-b-2 font-bold text-[10px] transition ${
isActive
? 'border-indigo-600 text-indigo-600 bg-white rounded-t-lg shadow-sm'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:bg-slate-100/60 rounded-t-lg'
}">
<i class="fa-solid ${h.icon} text-xs"></i>
<span class="truncate">${h.label}</span>
</button>
`;
}).join('')}
</div>
`;
// 2. Подвкладки реестров (Сетка 2x2)
let subTabsHtml = '';
if (this.subTabs[this.currentHub]) {
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
subTabsHtml = `
<div class="grid grid-cols-2 gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
${this.subTabs[this.currentHub].map(st => {
const isSubActive = currentActiveSub === st.id;
return `
<button onclick="SidebarManager.setSubTab('${st.id}')"
class="py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
isSubActive
? 'bg-white text-indigo-700 shadow-sm'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
}">
<i class="fa-solid ${st.icon} text-[10px]"></i>
<span>${st.label}</span>
</button>
`;
}).join('')}
</div>
`;
}
headerContainer.innerHTML = hubsHtml + subTabsHtml;
},
renderContent() {
const contentContainer = document.getElementById("sidebar-dynamic-content");
if (!contentContainer) return;
contentContainer.scrollTop = 0;
switch (this.currentHub) {
case 'TASKS':
this.renderTasksView(contentContainer);
break;
case 'SNAPSHOTS':
this.renderSnapshotsView(contentContainer);
break;
case 'REGISTRIES':
this.renderRegistriesView(contentContainer);
break;
case 'PROMPT':
this.renderPromptView(contentContainer);
break;
case 'CONTEXT':
this.renderContextView(contentContainer);
break;
default:
contentContainer.innerHTML = `<div class="p-4 text-xs text-slate-400 text-center">Раздел в разработке</div>`;
}
},
// =========================================================================
// ХАБ 1: ЗАДАЧИ
// =========================================================================
renderTasksView(container) {
container.innerHTML = `
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
</div>
</div>
`;
if (window.loadTasks) {
window.loadTasks();
}
},
// =========================================================================
// ХАБ 2: СРЕЗЫ СКУД
// =========================================================================
async renderSnapshotsView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
try {
const res = await fetch("/api/v1/snapshots", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : { snapshots: [] };
const snaps = data.snapshots || [];
if (snaps.length === 0) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400">Срезы СКУД не найдены</div>`;
return;
}
const itemsHtml = snaps.map(s => `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-bold text-slate-800">${escapeHtml(s.snapshot_id)}</span>
${s.is_final ? '<span class="px-1.5 py-0.2 rounded text-[9px] font-bold bg-amber-50 text-amber-700 border border-amber-200">Финал Y</span>' : ''}
</div>
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(s.snapshot_time)} · ${s.record_count || 0} зап.</div>
</div>
<button onclick="window.sendChatAction('покажи срез ${escapeHtml(s.snapshot_id)}')" class="px-2 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition" title="Открыть в чате">
Инспекция
</button>
</div>
`).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">Всего срезов: ${snaps.length}</span>
<button onclick="SidebarManager.renderSnapshotsView(document.getElementById('sidebar-dynamic-content'))" class="text-slate-400 hover:text-indigo-600 p-1" title="Обновить">
<i class="fa-solid fa-arrows-rotate text-xs"></i>
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">${itemsHtml}</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки срезов</div>`;
}
},
// =========================================================================
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ + МЕСТ. КОМАНДИР. + ИНОЕ)
// =========================================================================
renderRegistriesView(container) {
const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
if (subTab === 'REMOTE') {
this.renderRemoteWorkersView(container);
} else if (subTab === 'EXCEPTIONS') {
this.renderExceptionsView(container);
} else {
this.renderManualAbsencesView(container, subTab);
}
},
async renderRemoteWorkersView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>`;
try {
const res = await fetch("/api/v1/remote-workers", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : { workers: [] };
const workers = data.workers || [];
const listHtml = workers.map(w => {
const dFrom = w.date_from ? w.date_from : 'сегодня';
const dTo = w.date_to ? w.date_to : 'бессрочно';
const periodLabel = (!w.date_to) ? `с ${dFrom} (бессрочно)` : `${dFrom} — ${dTo}`;
return `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-emerald-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(w.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(w.department || 'Все')}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="openRemoteWorkerModal('EDIT', '${escapeHtml(w.fio)}', '${escapeHtml(w.department || 'Все')}', '${escapeHtml(w.date_from || '')}', '${escapeHtml(w.date_to || '')}')"
class="text-slate-400 hover:text-emerald-600 p-1.5 rounded-lg hover:bg-emerald-50 transition"
title="Изменить сроки удаленки">
<i class="fa-solid fa-pen-to-square text-xs"></i>
</button>
<button onclick="SidebarManager.deleteRemoteWorker('${escapeHtml(w.fio)}')"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${workers.length} чел.</span>
<button onclick="openRemoteWorkerModal('ADD')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${workers.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список удаленщиков пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра удаленщиков</div>`;
}
},
async deleteRemoteWorker(fio) {
if (!confirm(`Удалить сотрудника ${fio} из реестра удаленщиков?`)) return;
try {
const res = await fetch(`/api/v1/remote-workers?fio=${encodeURIComponent(fio)}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) {
this.renderContent();
} else {
alert("Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
},
async renderExceptionsView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка исключений...</div>`;
try {
const res = await fetch("/api/v1/exceptions/", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : {};
const categories = [
{ key: 'include_fio', title: 'Белый список (ФИО)' },
{ key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
{ key: 'departments', title: 'Исключенные отделы' },
{ key: 'positions', title: 'Исключенные должности' },
{ key: 'turnstile_fio', title: 'Пр. турникет (ФИО)', badge: 'Оба турникета' },
{ key: 'turnstile_departments', title: 'Пр. турникет (Отделы)', badge: 'Оба турникета' }
];
const html = categories.map(cat => {
const items = data[cat.key] || [];
const isTurnstile = cat.key.startsWith('turnstile_');
const badgeHtml = cat.badge
? `<span class="px-1.5 py-0.2 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">${cat.badge}</span>`
: '';
return `
<div class="bg-white border ${isTurnstile ? 'border-emerald-200/80 bg-emerald-50/10' : 'border-slate-200'} rounded-xl p-3 flex flex-col gap-2 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
${badgeHtml}
</div>
<button onclick="openExceptionModal('${cat.key}', '${cat.title}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
+ Добавить
</button>
</div>
<div class="flex flex-wrap gap-1">
${items.map(it => `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] ${isTurnstile ? 'bg-emerald-50 text-emerald-800 border border-emerald-200' : 'bg-slate-100 text-slate-700 border border-slate-200'}">
${escapeHtml(it)}
<button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
</span>
`).join('') || '<span class="text-[10px] text-slate-400">Пусто</span>'}
</div>
</div>
`;
}).join('');
container.innerHTML = `<div class="p-2 flex flex-col gap-2 max-h-[75vh] overflow-y-auto">${html}</div>`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки исключений</div>`;
}
},
async deleteExceptionItem(category, value) {
if (!confirm(`Удалить "${value}" из реестра?`)) return;
try {
const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) this.renderContent();
else alert("Ошибка удаления");
} catch (e) {
alert("Ошибка сети");
}
},
async renderManualAbsencesView(container, absenceType) {
const typeLabel = absenceType === 'LOCAL_TRIP' ? 'местных командировок' : 'иных отсутствий';
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка ${typeLabel}...</div>`;
try {
const res = await fetch(`/api/v1/manual-absences/?type=${absenceType}`);
const data = res.ok ? await res.json() : { items: [] };
const items = data.items || [];
const listHtml = items.map(it => {
const dFrom = it.date_start ? it.date_start : 'сегодня';
const dTo = it.date_end ? it.date_end : 'сегодня';
const periodLabel = (dFrom === dTo) ? `на ${dFrom}` : `${dFrom} — ${dTo}`;
const badgeText = absenceType === 'LOCAL_TRIP' ? 'Местная командировка' : escapeHtml(it.reason);
return `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(it.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(it.department || 'Все')} · ${badgeText}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="SidebarManager.deleteManualAbsenceRecord(${it.id})"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${items.length} чел.</span>
<button onclick="openManualAbsenceModal('${absenceType}')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${items.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра</div>`;
}
},
async deleteManualAbsenceRecord(id) {
if (!confirm("Удалить эту запись из реестра?")) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: "DELETE" });
if (res.ok) {
this.renderContent();
} else {
alert("Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
},
// =========================================================================
// ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
// =========================================================================
renderPromptView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-1.5">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-sliders text-indigo-600"></i> Инструкции и регламенты ИИ
</span>
<span>Управление системными директивами, базой знаний и правилами арбитража кадровых аномалий СКУД и 1С.</span>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('покажи системный промпт')"
class="w-full py-2 px-3 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-terminal text-xs"></i>
<span>Показать системный промпт</span>
</button>
<button onclick="window.sendChatAction('покажи правила компании')"
class="w-full py-2 px-3 bg-white hover:bg-slate-50 active:bg-slate-100 text-slate-700 border border-slate-300 rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-book-bookmark text-emerald-600 text-xs"></i>
<span>База знаний и правила компании</span>
</button>
</div>
</div>
`;
},
// =========================================================================
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ
// =========================================================================
renderContextView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-2">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-brain text-indigo-600"></i> Управление памятью чата
</span>
<div class="flex flex-col gap-1.5 pt-1 border-t border-slate-100">
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-amber-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Мягкая очистка:</span>
<span class="text-slate-500"> удаляет только служебные транзакции (карточки срезов, временные превью промпта, промежуточные подтверждения). Смысловой диалог пользователя сохраняется.</span>
</div>
</div>
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-rose-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Полный сброс:</span>
<span class="text-slate-500"> полностью стирает контекст активной сессии из базы данных и очищает окно чата. Используется при переходе к новой дате или новой теме анализа.</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('очисти контекст')"
class="w-full py-2 px-3 bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-broom text-xs"></i>
<span>Мягкая очистка контекста</span>
</button>
<button onclick="SidebarManager.handleFullSessionReset()"
class="w-full py-2 px-3 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-trash-arrow-up text-xs"></i>
<span>Полный сброс сессии</span>
</button>
</div>
</div>
`;
},
async handleFullSessionReset() {
if (!confirm("Вы действительно хотите полностью очистить историю диалога и сбросить сессию чата?")) {
return;
}
const chatContainer = document.getElementById("chat-messages-container");
if (chatContainer) {
chatContainer.innerHTML = `
<div class="flex gap-3 max-w-4xl mx-auto w-full">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-xs"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-xs text-slate-700 leading-relaxed">
Сессия чата очищена. Память ассистента сброшена. Задайте новый вопрос или команду.
</div>
</div>
</div>
`;
}
try {
await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: "сбрось сессию полностью",
session_id: "web_session_main"
})
});
} catch (e) {
console.error("Ошибка запроса сброса сессии:", e);
}
}
};
// ============================================================================
// МОДАЛЬНОЕ ОКНО ИСКЛЮЧЕНИЙ И АВТОКОМПЛИТ 1С
// ============================================================================
window.openExceptionModal = function(category, title = "") {
const modal = document.getElementById("exception-modal");
const headerText = document.getElementById("exception-modal-header-text");
const catInput = document.getElementById("exception-category-input");
const valInput = document.getElementById("exception-value-input");
const labelEl = document.getElementById("exception-value-label");
const commentInput = document.getElementById("exception-comment-input");
const errEl = document.getElementById("exception-error-msg");
const suggestionsBox = document.getElementById("exception-suggestions");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (suggestionsBox) {
suggestionsBox.classList.add("hidden");
suggestionsBox.innerHTML = "";
}
if (catInput) catInput.value = category;
if (commentInput) commentInput.value = "";
if (valInput) valInput.value = "";
if (headerText) headerText.innerText = title || "Добавление в реестр";
if (labelEl && valInput) {
if (category.includes("fio")) {
labelEl.innerText = "ФИО сотрудника (автоподбор из 1С):";
valInput.placeholder = "Начните вводить фамилию...";
} else if (category.includes("department")) {
labelEl.innerText = "Подразделение:";
valInput.placeholder = "Например: ЭТО, ЛЦ, ОВК";
} else {
labelEl.innerText = "Должность:";
valInput.placeholder = "Например: Уборщик, Слесарь";
}
}
modal.classList.remove("hidden");
if (valInput) valInput.focus();
};
window.closeExceptionModal = function() {
const modal = document.getElementById("exception-modal");
if (modal) modal.classList.add("hidden");
};
window.submitExceptionModalForm = async function(e) {
e.preventDefault();
const category = document.getElementById("exception-category-input").value;
const value = document.getElementById("exception-value-input").value.trim();
const comment = document.getElementById("exception-comment-input")?.value.trim() || "";
const errEl = document.getElementById("exception-error-msg");
if (!value) return;
try {
const res = await fetch("/api/v1/exceptions/", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ category: category, value: value, comment: comment })
});
if (res.ok) {
closeExceptionModal();
if (window.SidebarManager) SidebarManager.renderContent();
} else {
const err = await res.json().catch(() => ({}));
if (errEl) {
errEl.innerText = err.detail || "Ошибка сохранения";
errEl.classList.remove("hidden");
}
}
} catch (err) {
if (errEl) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
};
let excSearchTimeout = null;
document.addEventListener("DOMContentLoaded", () => {
if (window.AuthManager && AuthManager.isAuthenticated()) {
SidebarManager.init();
}
const inputEl = document.getElementById("exception-value-input");
const box = document.getElementById("exception-suggestions");
if (inputEl && box) {
inputEl.addEventListener("input", function() {
const category = document.getElementById("exception-category-input")?.value || "";
if (!category.includes("fio")) {
box.classList.add("hidden");
return;
}
const val = this.value.trim();
clearTimeout(excSearchTimeout);
if (val.length < 2) {
box.classList.add("hidden");
box.innerHTML = "";
return;
}
excSearchTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
if (!res.ok) return;
const items = await res.json();
if (items.length === 0) {
box.classList.add("hidden");
return;
}
box.innerHTML = items.map(it => `
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
onclick="selectExceptionStaff('${escapeHtml(it.fio)}')">
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
</div>
`).join("");
box.classList.remove("hidden");
} catch (e) {
console.error(e);
}
}, 200);
});
}
});
window.selectExceptionStaff = function(fio) {
const inputEl = document.getElementById("exception-value-input");
const box = document.getElementById("exception-suggestions");
if (inputEl) inputEl.value = fio;
if (box) {
box.classList.add("hidden");
box.innerHTML = "";
}
};
+84 -167
View File
@@ -1,200 +1,117 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/tasks.js
* ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
* ===============================================================================
*/
===============================================================================
FILE: modules/web_api/static/js/tasks.js
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / static / js
ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer).
===============================================================================
*/
let currentTasksFilter = 'ALL';
let tasksCache = [];
function getTasksContainer() {
return document.getElementById("tasks-list-container") ||
document.getElementById("sidebar-dynamic-content") ||
document.getElementById("tasks-list");
}
let currentTaskFilter = 'IN_PROGRESS';
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>
`;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
if (!token) return;
try {
const res = await fetch("/api/v1/tasks", {
headers: AuthManager.getAuthHeaders()
headers: { "Authorization": "Bearer " + token }
});
if (res.ok) {
const data = await res.json();
renderSidebarTasks(data.tasks || []);
} else {
renderSidebarError("Ошибка доступа. Авторизуйтесь снова.");
}
} catch (e) {
console.error("Ошибка загрузки задач:", e);
renderSidebarError("Ошибка сети. Сервер недоступен.");
}
}
function renderSidebarError(msg) {
// Поддерживаем оба варианта ID (новый и старый) для обратной совместимости
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
if (container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-rose-500 font-semibold">${msg}</div>`;
}
}
function filterTasksByTab(status) {
currentTaskFilter = status;
// Сброс стилей всех кнопок-вкладок
document.querySelectorAll('.task-tab-btn').forEach(btn => {
btn.classList.remove('text-indigo-600', 'bg-indigo-50');
btn.classList.add('text-slate-600');
});
if (!res.ok) {
if (res.status === 401) {
showAuthModal();
return;
// Установка активного стиля для выбранной вкладки
const activeBtnId = {
'IN_PROGRESS': 'tab-in-progress',
'BACKLOG': 'tab-backlog',
'COMPLETED': 'tab-completed',
'ALL': 'tab-all'
}[status];
if (activeBtnId) {
const btn = document.getElementById(activeBtnId);
if (btn) {
btn.classList.remove('text-slate-600', 'hover:bg-slate-100');
btn.classList.add('text-indigo-600', 'bg-indigo-50');
}
throw new Error(`Ошибка сервера (${res.status})`);
}
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>
`;
}
loadTasks();
}
function setTaskFilter(filter) {
currentTasksFilter = filter;
renderTasksUI();
}
function renderTasksUI() {
const container = getTasksContainer();
function renderSidebarTasks(tasks) {
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
if (!container) return;
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');
let filtered = tasks;
if (currentTaskFilter !== 'ALL') {
filtered = tasks.filter(t => t.status === currentTaskFilter);
}
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>
`;
container.innerHTML = `<div class="text-center py-8 text-[11px] font-medium text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-200">Нет задач в этой категории</div>`;
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'];
container.innerHTML = filtered.map(t => {
const isCompleted = t.status === 'COMPLETED';
const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL'
? 'text-rose-600 bg-rose-50 border-rose-200'
: t.priority === 'MEDIUM'
? 'text-amber-600 bg-amber-50 border-amber-200'
: 'text-slate-600 bg-slate-50 border-slate-200';
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="p-3 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-indigo-400 hover:shadow-md transition cursor-pointer flex flex-col gap-2 group"
onclick="handleActionButtonClick('покажи задачу ${t.id}')">
<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>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider group-hover:text-indigo-500 transition">#${t.id}</span>
${isCompleted
? `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md bg-emerald-50 text-emerald-600 border border-emerald-200 shadow-sm"><i class="fa-solid fa-check mr-0.5"></i> Готово</span>`
: `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md ${priorityColor} shadow-sm">${t.priority || 'LOW'}</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 class="text-xs font-semibold text-slate-700 leading-snug line-clamp-3">${escapeHtml(t.title || 'Без названия')}</div>
<div class="flex items-center justify-between text-[10px] text-slate-400 mt-1">
<span class="bg-slate-100 px-1.5 py-0.5 rounded font-mono truncate max-w-[120px]">${escapeHtml(t.module || 'general')}</span>
${t.due_date ? `<span class="shrink-0 font-medium text-slate-500"><i class="fa-regular fa-calendar mr-1"></i>${escapeHtml(t.due_date)}</span>` : ''}
</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"
})
});
if (res.ok) {
loadTasks();
} else {
alert("Не удалось создать задачу");
}
} catch (e) {
alert("Ошибка сети");
}
}
window.loadTasks = loadTasks;
window.setTaskFilter = setTaskFilter;
// Глобальная инициализация при загрузке DOM
document.addEventListener("DOMContentLoaded", () => {
// Небольшая задержка, чтобы гарантировать применение токена
setTimeout(loadTasks, 200);
});
@@ -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,56 +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>Добавление в реестр</span>
</h3>
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="flex flex-col gap-3">
<div class="relative">
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
<input type="text" id="manual-absence-fio-input" autocomplete="off" placeholder="Начните вводить фамилию..."
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" />
<div id="manual-absence-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
</div>
<input type="hidden" id="manual-absence-dept" />
<input type="hidden" id="manual-absence-pos" />
<div id="manual-absence-reason-block" class="hidden">
<label class="block text-[11px] font-bold text-slate-600 mb-1">Причина отсутствия:</label>
<select id="manual-absence-reason-select" class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50"></select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Начало:</label>
<input type="date" id="manual-absence-start-date"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = сегодня</span>
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
<input type="date" id="manual-absence-end-date"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">По умолчанию: сегодня</span>
</div>
</div>
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
<button type="button" onclick="closeManualAbsenceModal()"
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
Отмена
</button>
<button type="button" onclick="submitManualAbsence()"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">
Сохранить
</button>
</div>
</div>
</div>
</div>
@@ -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,57 +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>
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
<input type="text" id="rw-fio" required placeholder="Например: Иванов Иван Иванович"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Подразделение:</label>
<input type="text" id="rw-dept" placeholder="Все"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
<input type="date" id="rw-date-from"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = с сегодняшнего дня</span>
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
<input type="date" id="rw-date-to"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = бессрочно</span>
</div>
</div>
<div id="rw-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
<button type="button" onclick="closeRemoteWorkerModal()"
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
Отмена
</button>
<button type="submit" id="rw-submit-btn"
class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition">
Сохранить
</button>
</div>
</form>
</div>
</div>
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "[CRON HOURLY SNAPSHOT START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
# Запуск ТОЛЬКО экспорта среза СКУД без тяжелых отчетов:
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/services/scud_export.py >> /home/puh/projects/scud_ai/logs/cron_hourly.log 2>&1
echo "[CRON HOURLY SNAPSHOT FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1
echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "[CRON HOURLY START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/services/scud_export.py >> /home/puh/projects/scud_ai/logs/cron_hourly.log 2>&1
echo "[CRON HOURLY FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py --skip-export >> /home/puh/projects/scud_ai/logs/cron_reports.log 2>&1
echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log
+70 -183
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,7 +25,6 @@ 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
@@ -91,11 +42,8 @@ def print_tool_actions():
print(f" • Вопрос: {r[5]}")
print(f" • Кнопки: {r[6]}")
print("-" * 110)
except Exception as e:
print(f"Таблица tool_action_registry недоступна: {e}")
print("=" * 110 + "\n")
def print_stats():
"""Выводит общую статистику по записям в таблицах БД."""
print("\n" + "=" * 60)
@@ -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,48 +333,41 @@ 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")
with get_connection() as conn:
cursor = conn.cursor()
# ⭐️ Новые функции управления исключениями (Exceptions & Whitelist)
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}")
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:
print(" — пусто")
print("-" * 80)
print("=" * 80 + "\n")
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_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(" Нет данных о проходах за указанную дату.")
query = """
DELETE FROM chat_messages
WHERE is_ephemeral = 1
OR content LIKE '%Предпросмотр изменений%'
OR content LIKE '%Актуальный системный промпт:%'
OR content LIKE '%1. РОЛЬ И ЗАДАЧИ АССИСТЕНТА%'
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(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}")
cursor.execute(query)
cursor.execute("DELETE FROM session_states")
print("=" * 80 + "\n")
deleted_msgs = cursor.rowcount
conn.commit()
print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
HELP_TEXT = """
@@ -440,7 +377,6 @@ CLI-утилита инспекции и управления SQLite базой
stats -- Общая статистика строк по всем таблицам БД
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
in_building [ДД.ММ.ГГГГ] [--all] -- Оперативный статус: кто сейчас в здании (или все статусы с флагом --all)
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
@@ -452,17 +388,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
@@ -473,11 +402,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
"""
@@ -491,16 +415,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'], 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="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
@@ -516,13 +433,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()
@@ -534,10 +450,6 @@ def main():
print_session_states()
elif args.command == 'tools':
print_tool_actions()
elif args.command in ('in_building', 'presence'):
# Принимаем дату из позиционного параметра action (или param), либо берем текущую
target_date = args.action if args.action else datetime.now().strftime("%d.%m.%Y")
print_building_presence(target_date, all_statuses=args.all)
elif args.command == 'context':
if args.action in ['purge', 'clear']:
is_all = args.all or (args.param == '--all')
@@ -547,7 +459,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':
@@ -561,34 +473,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)")
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env python3
"""
===============================================================================
FILE: scripts/diagnostics/make_etl_snapshot.py
ROLE: Генерация компактного слепка ETL-конвейера, генераторов отчетов и БД.
===============================================================================
"""
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")
TARGET_FILES = [
# Конфигурация и точка входа
"config.py",
"exceptions.json",
"main_etl.py",
"scripts/db_cli.py",
# Крон скрипты
"scripts/cron/run_hourly_snapshot.sh",
"scripts/cron/run_reports_only.sh",
"scripts/cron/run_cron_etl.sh",
# Ядро БД
"core/connection.py",
"core/database.py",
"core/schema.py",
"core/repositories/scud_repo.py",
"core/repositories/zup_repo.py",
# Сервисный слой загрузки и реестров
"services/data_loader.py",
"services/scud_export.py",
"services/share_copier.py",
"services/excel_exporter.py",
"services/exceptions_repo.py",
"services/manual_absences_repo.py",
"services/zup_extractor.py",
"services/ai_verifier.py",
"services/knowledge_base.py",
"services/knowledge/service.py",
# Модули генерации отчетов Excel
"services/reports/styles.py",
"services/reports/calculators.py",
"services/reports/svodka_builder.py",
"services/reports/otchet_builder.py",
"services/reports/raw_scud_builder.py",
# Модули сборки Сводки, Отчета и запросы
"services/scud_etl/pipeline.py",
"services/scud_etl/merger.py",
"services/scud_etl/svodka_generator.py",
"services/scud_etl/otchet_generator.py",
"services/scud_etl/anomaly_detector.py",
"services/scud_etl/sql_queries.py",
"services/snapshots/service.py",
"services/tasks/repository.py",
"services/tasks/service.py"
]
def create_etl_snapshot():
content = ["# 📦 ETL-СЛЕПОК ИСХОДНОГО КОДА (СКУД ⟷ 1С & DB CORE)\n"]
included_count = 0
for rel_path in TARGET_FILES:
full_path = os.path.join(ROOT_DIR, rel_path)
if os.path.exists(full_path):
ext = os.path.splitext(rel_path)[1].replace(".", "")
lang_map = {
"py": "py",
"json": "json",
"sh": "bash"
}
lang = lang_map.get(ext, "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
else:
print(f"[ℹ️] Пропущен отсутствующий файл: {rel_path}")
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()
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env python3
"""
===============================================================================
FILE: scripts/diagnostics/make_web_snapshot.py
ROLE: Генерация актуального слепка Web API, фронтенда (HTML/JS) и LLM-движка.
===============================================================================
"""
import os
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
OUTPUT_FILE = os.path.join(ROOT_DIR, "web_api_code_snapshot.md")
WEB_TARGET_FILES = [
# Главная точка входа и роутеры
"modules/web_api/main.py",
"modules/web_api/routers/chat.py",
"modules/web_api/routers/remote_workers.py",
"modules/web_api/routers/manual_absences.py",
"modules/web_api/routers/exceptions.py",
"modules/web_api/routers/snapshots.py",
"modules/web_api/routers/tasks.py",
"modules/web_api/routers/auth.py",
"modules/web_api/routers/admin.py",
"modules/web_api/routers/files.py",
"modules/web_api/routers/context.py",
# LLM ядро
"modules/web_api/llm/agent.py",
"modules/web_api/llm/schemas.py",
"modules/web_api/llm/db_tools.py",
"modules/web_api/llm/core/fast_path.py",
"modules/web_api/llm/core/context_manager.py",
"modules/web_api/llm/core/ollama_client.py",
"modules/web_api/llm/core/tool_injector.py",
# Фронтенд (Разметка и клиентские скрипты)
"modules/web_api/static/index.html",
"modules/web_api/static/js/sidebar.js",
"modules/web_api/static/js/tasks.js",
"modules/web_api/static/js/manual_absences.js",
"modules/web_api/static/js/chat/core.js",
"modules/web_api/static/js/chat/task_widget.js",
"modules/web_api/static/js/auth.js",
"modules/web_api/static/js/app.js"
]
def create_web_snapshot():
content = ["# 🌐 WEB API & FRONTEND CODE SNAPSHOT\n"]
included_count = 0
for rel_path in WEB_TARGET_FILES:
full_path = os.path.join(ROOT_DIR, rel_path)
if os.path.exists(full_path):
ext = os.path.splitext(rel_path)[1].replace(".", "")
lang_map = {
"js": "js",
"py": "py",
"html": "html",
"css": "css",
"json": "json"
}
lang = lang_map.get(ext, "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
else:
print(f"[ℹ️] Пропущен отсутствующий файл: {rel_path}")
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)")
+165 -146
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
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():
fio_raw = str(s_row.get('fio', '')).strip()
if not fio_raw:
continue
fio_c = normalize_fio(fio_raw)
reason = str(s_row.get('reason', '')).strip() or "Дистанционная работа"
d_from = parse_date_safe(s_row.get('date_from'))
d_to = parse_date_safe(s_row.get('date_to'))
# 1. Физическая ротация просроченных: только если текущий реальный день (today) строго больше date_to
if d_to is not None and today_date > d_to:
print(f" [🧹] Удаленка истекла: {fio_raw} (до {d_to.strftime('%d.%m.%Y')}). Удалена из CSV.")
file_changed = True
continue
active_for_file.append(s_row.to_dict())
# 2. Проверка действия удаленки на дату формируемого отчета:
# Если date_from не указана — действует всегда до date_to
is_after_start = (d_from is None) or (clean_target_date >= d_from)
is_before_end = (d_to is None) or (clean_target_date <= d_to)
if is_after_start and is_before_end:
if fio_c not in existing_fios:
new_rows_for_report.append({
'fio_clean': fio_c,
'Вид_отсутствия': reason
if 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
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)
-92
View File
@@ -1,92 +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": []
}
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"] 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}")
-214
View File
@@ -1,214 +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]]:
init_manual_absences_table()
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
if absence_type:
cursor.execute("""
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason, comment, created_at
FROM manual_absences
WHERE absence_type = ?
ORDER BY id DESC
""", (absence_type.upper(),))
else:
cursor.execute("""
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason, comment, created_at
FROM manual_absences
ORDER BY id DESC
""")
return [dict(r) for r in cursor.fetchall()]
def get_active_manual_absences_for_date(date_str: str) -> List[Dict[str, Any]]:
"""Выбирает записи, активные на дату отчета (формат даты ДД.ММ.ГГГГ)."""
init_manual_absences_table()
try:
dt_target = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
dt_target = datetime.now().strftime("%Y-%m-%d")
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason
FROM manual_absences
WHERE (date_start IS NULL OR date_start <= ?)
AND (date_end IS NULL OR date_end >= ?)
""", (dt_target, dt_target))
return [dict(r) for r in cursor.fetchall()]
-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"
-95
View File
@@ -1,95 +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"
df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() if merged_df is not None and not merged_df.empty else pd.DataFrame()
target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename)
wb = xlsxwriter.Workbook(output_path)
ws = wb.add_worksheet("Детальный_отчет")
fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True})
ws.write(1, 1, "Дата:", fmt_date_lbl)
ws.write(1, 3, date_clean, fmt_date_lbl)
headers = [
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
]
fmt_hdr = create_xlsx_format(wb, font_name='Arial', font_size=10, bg_color='#D9E1F2', bold=True, align="center", wrap=True)
ws.set_row(3, 26)
for col_idx, h_text in enumerate(headers):
ws.write(3, col_idx, h_text, fmt_hdr)
start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании'
chars_per_line_h = 24
for idx, row in df_export.reset_index(drop=True).iterrows():
row_num = 4 + idx
is_present = row.get('Пришел', False)
absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
in_val = str(row.get(start_col, 'Нет входа')).strip()
out_val = str(row.get(end_col, 'Нет выхода')).strip()
in_building_str = str(row.get(hours_col, '00:00'))
first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
row_color = None
if is_present and has_reason:
row_color = '#E2EFDA'
elif not is_present and has_reason:
row_color = '#FFF2CC'
elif not is_present and not has_reason and not has_first_act:
row_color = '#FCE4D6'
val_h_str = str(absence_reason) if has_reason else ""
lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1
ws.set_row(row_num, max(lines_count * 18, 20))
values = [
(idx + 1, 'center', False),
(row.get('Сотрудник', ''), 'left', False),
(dept_scud_val, 'center', False),
(in_val, 'center', False),
(first_act_val, 'center', False),
(out_val, 'center', False),
(in_building_str, 'center', False),
(absence_reason if has_reason else '', 'left', True),
(8, 'center', False),
(deviation_val, 'center', False)
]
for col_idx, (val, align_type, is_wrap) in enumerate(values):
fmt = create_xlsx_format(wb, font_name='Arial', font_size=10, bg_color=row_color, align=align_type, wrap=is_wrap)
ws.write(row_num, col_idx, val, fmt)
col_widths = {0: 4, 1: 33, 2: 13, 3: 11, 4: 11, 5: 11, 6: 12, 7: 24, 8: 6, 9: 11}
for col_idx, width in col_widths.items():
ws.set_column(col_idx, col_idx, width)
safe_close_workbook(wb, output_path, target_dir, filename)
-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)
-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)
-191
View File
@@ -1,191 +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)
ws = wb.add_worksheet("Лист_1")
ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False)
fmt_hdr_l = create_xlsx_format(wb, bg_color='#D9E1F2', bold=True, align="left")
fmt_hdr_r = create_xlsx_format(wb, bg_color='#D9E1F2', bold=True, align="right")
fmt_tot_l = create_xlsx_format(wb, bg_color='#F2F2F2', bold=True, align="left")
fmt_tot_r = create_xlsx_format(wb, bg_color='#F2F2F2', bold=True, align="right")
fmt_empty = create_xlsx_format(wb)
ws.set_row(0, 20)
ws.write(0, 0, "Сводка на", fmt_hdr_l)
ws.write(0, 1, date_clean, fmt_hdr_r)
ws.set_row(1, 20)
ws.write(1, 0, "", fmt_empty)
ws.write(1, 1, "", fmt_empty)
ws.set_row(2, 20)
ws.write(2, 0, "По списку", fmt_tot_l)
ws.write(2, 1, len(merged_df), fmt_tot_r)
current_row = 3
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
is_exc = merged_df.get('is_excluded', False) == True
# 1. Неизвестно
unexplained = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass) & (~is_exc)
]
fmt_unexp_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left")
fmt_unexp_hr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="right")
fmt_unexp_rl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left")
fmt_unexp_rr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "неизвестно", fmt_unexp_hl)
ws.write(current_row, 1, len(unexplained), fmt_unexp_hr)
current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
ws.write(current_row, 0, fio, fmt_unexp_rl)
ws.write(current_row, 1, "", fmt_unexp_rr)
current_row += 1
# 2. Нет пропуска
no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
fmt_np_hl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="left")
fmt_np_hr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="right")
fmt_np_rl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="left")
fmt_np_rr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Нет пропуска", fmt_np_hl)
ws.write(current_row, 1, len(no_pass_df), fmt_np_hr)
current_row += 1
if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
ws.write(current_row, 0, fio, fmt_np_rl)
ws.write(current_row, 1, "", fmt_np_rr)
current_row += 1
# 3. Официальные отсутствия
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
absent_only = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason)
]
absent_groups = absent_only.groupby('Вид_отсутствия')
pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF']
for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_c = pastels[idx_cat % len(pastels)]
fmt_cat_hl = create_xlsx_format(wb, bg_color=hex_c, bold=True, align="left")
fmt_cat_hr = create_xlsx_format(wb, bg_color=hex_c, bold=True, align="right")
fmt_cat_rl = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="left")
fmt_cat_rr = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, cat_name, fmt_cat_hl)
ws.write(current_row, 1, len(group), fmt_cat_hr)
current_row += 1
is_other_category = (str(cat_name).strip().lower() == "иное")
for _, row in group.sort_values(by='Сотрудник').iterrows():
fio = row.get('Сотрудник', '')
detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else ""
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, fio, fmt_cat_rl)
ws.write(current_row, 1, detail_val, fmt_cat_rr)
current_row += 1
# 4. Итого на работе
exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))]
present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
total_present_count = len(present_scud) + len(exc_without_doc)
fmt_pres_hl = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="left")
fmt_pres_hr = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Итого на работе", fmt_pres_hl)
ws.write(current_row, 1, total_present_count, fmt_pres_hr)
current_row += 1
# 5. Удаленная работа
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
fmt_rem_hl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="left")
fmt_rem_hr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="right")
fmt_rem_rl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="left")
fmt_rem_rr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl)
ws.write(current_row, 1, len(remote_home), fmt_rem_hr)
current_row += 1
if not remote_home.empty:
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, fio, fmt_rem_rl)
ws.write(current_row, 1, "", fmt_rem_rr)
current_row += 1
# 6. Аномалии СКУД и 1С
anomalies = merged_df[
(~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason) &
(~reason_clean.str.contains('командировк', na=False))) |
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
)
]
fmt_anom_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left")
fmt_anom_hr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="right")
fmt_anom_rl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left")
fmt_anom_rr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left", wrap=True)
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl)
ws.write(current_row, 1, len(anomalies), fmt_anom_hr)
current_row += 1
chars_per_line_b = 30
if not anomalies.empty:
for _, row in anomalies.iterrows():
fio = row.get('Сотрудник', '')
anom_flag = row.get('anomaly_flag', 'NONE')
reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {row.get('Первая_активность', '—')})" if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY' else f"В 1С: {row.get('Вид_отсутствия', '')}"
lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
ws.set_row(current_row, max(lines_count * 18, 20), None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, fio, fmt_anom_rl)
ws.write(current_row, 1, reason_text, fmt_anom_rr)
current_row += 1
ws.set_column(0, 0, 45)
ws.set_column(1, 1, 38)
safe_close_workbook(wb, output_path, target_dir, filename)
+18 -38
View File
@@ -1,71 +1,51 @@
"""
===============================================================================
FILE: services/scud_etl/anomaly_detector.py
ROLE: Детектирование аномалий СКУД, дубликатов пропусков и несоответствий с 1С.
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Детектирование истинных аномалий и конфликтов реестров.
(Приход удаленщиков и командированных в офис аномалией НЕ является).
===============================================================================
"""
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]]:
def detect_registry_anomalies(df_merged: pd.DataFrame) -> 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 = str(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":
# 1. Приход в офис при отпуске / больничном
if start_day != "Нет входа" 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}'."
"description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'."
})
# Ошибка считывателя (есть выход без входа)
if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]:
# 2. Аномалия оборудования (есть выход без входа)
if start_day == "Нет входа" and end_day != "Нет выхода":
anomalies.append({
"fio": fio,
"type": "SCUD_EQUIPMENT_ANOMALY",
"description": f"Зафиксирован выход ({end_day}) при отсутствии отметки утреннего входа."
"description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})."
})
return anomalies
+52 -216
View File
@@ -1,8 +1,10 @@
"""
===============================================================================
FILE: services/scud_etl/merger.py
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки.
Распределение исключений в общий рабочий пул при отсутствии справок.
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Агрегация реестров СКУД и 1С, наложение исключений, нормализация кодов
подразделений и расчет сходящегося баланса присутствия.
===============================================================================
"""
@@ -10,259 +12,93 @@ 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
# Используем правильные имена функций из services/knowledge/service.py
from services.knowledge.service import get_department_synonyms_dict, get_rules
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'
])
"""
Объединяет реестры СКУД и 1С:ЗУП:
- Применяет исключения (уборщики, ОВК и др.).
- Устанавливает короткие аббревиатуры подразделений (КО, ОАН, РУК и др.).
- Привязывает кадровые документы отсутствий из 1С.
"""
# Получаем словарь синонимов
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)
df_res = df_scud_agg.copy() if df_scud_agg is not None and not df_scud_agg.empty else df_staff_agg.copy()
df_res = df_scud.copy()
if "Сотрудник" in df_res.columns:
df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio)
elif "ФИО" in df_res.columns:
df_res["Сотрудник"] = df_res["ФИО"]
df_res["fio_clean"] = df_res["ФИО"].apply(normalize_fio)
elif "fio_clean" not in df_res.columns:
df_res["fio_clean"] = ""
df_res["fio_clean"] = df_res["Сотрудник"].astype(str).str.strip()
for col, default_val in [
('Начало_дня', 'Нет входа'),
('Первая_активность', '—'),
('Конец_дня', 'Нет выхода'),
('Находился_в_здании', '00:00'),
('Пришел', False),
('anomaly_flag', 'NONE')
]:
if col not in df_res.columns:
df_res[col] = default_val
# 1. Фильтрация системных исключений
# Загружаем исключения из базы знаний или задаем системный фильтр
excluded_depts = {"ОВК", "Отдел вневедомственного контроля", "Служба уборки", "Клининг"}
excluded_positions = {"Уборщик", "Уборщица", "Дворник"}
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, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"}
if not df_res.empty:
if "Подразделение" in df_res.columns:
df_res = df_res[~df_res["Подразделение"].astype(str).isin(excluded_depts)]
if "Должность" in df_res.columns:
df_res = df_res[~df_res["Должность"].astype(str).isin(excluded_positions)]
# 2. Трансляция подразделений в короткие аббревиатуры СКУД
if "Подразделение" in df_res.columns:
df_res["Подразделение"] = df_res["Подразделение"].apply(
lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip())
lambda d: synonyms.get(d, d)
)
# 3. Привязка документов отсутствий из 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:
if not df_absences_1c.empty and "Сотрудник" in df_absences_1c.columns and "Причина" in df_absences_1c.columns:
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() != "nan":
fio = str(row["Сотрудник"]).strip()
reason = str(row["Причина"]).strip()
absences_map[fio] = reason
manual_reasons_map = {}
try:
from services.manual_absences_repo import get_active_manual_absences_for_date
# date_clean берется из даты контекста либо из текущих суток
target_date_val = df_res.get('Дата', pd.Series()).iloc[0] if 'Дата' in df_res.columns and not df_res.empty else None
if target_date_val:
m_records = get_active_manual_absences_for_date(str(target_date_val))
for mr in m_records:
manual_reasons_map[mr['fio_clean']] = mr['reason']
except Exception:
pass
df_res["detailed_reason"] = df_res["fio_clean"].map(manual_reasons_map).fillna("")
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
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:
if has_official_absence:
df_res.at[idx, "is_excluded"] = False
else:
df_res.at[idx, "is_excluded"] = True
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]:
"""
Расчет строго сходящегося баланса присутствия:
A (Итого на работе) + B (Удаленная работа) + C (Официально отсутствуют) + D (Неизвестно) = N (Всего)
"""
total_staff = len(df_merged)
came_to_office_mask = (df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (df_merged.get("is_excluded", False) == False)
exc_without_doc_mask = (df_merged.get("is_excluded", False) == True) & (
df_merged["Вид_отсутствия"].isna() |
df_merged["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"])
)
# 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис)
came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")
working_in_office = df_merged[came_to_office_mask]
working_in_office_count = len(working_in_office)
working_in_office_count = len(df_merged[came_to_office_mask | exc_without_doc_mask])
# 2. Все, кто сегодня НЕ пришел в офис
not_came_mask = ~came_to_office_mask
df_not_came = df_merged[not_came_mask]
df_not_working = df_merged[~came_to_office_mask & ~exc_without_doc_mask]
reason_series = df_not_working["причина отсутствия"].astype(str).str.lower()
# 3. Из непришедших выделяем удаленщиков (работают из дома)
reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home = df_not_working[is_remote_mask]
remote_home = df_not_came[is_remote_mask]
remote_home_count = len(remote_home)
df_remaining_absent = df_not_working[~is_remote_mask]
# 4. Из оставшихся непришедших выделяем официальные отсутствия (отпуск, больничный, командировка и т.д.)
df_remaining_absent = df_not_came[~is_remote_mask]
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 = len(df_remaining_absent[has_doc_mask])
df_remaining_absent["причина отсутствия"].ne("nan")
official_absent = df_remaining_absent[has_doc_mask]
official_absent_count = len(official_absent)
# 5. Оставшиеся непришедшие без документов — истинно неизвестные
unknown = df_remaining_absent[~has_doc_mask]
unknown_count = len(unknown)
-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} успешно сформирован."
}
+34 -32
View File
@@ -1,64 +1,66 @@
"""
===============================================================================
FILE: services/scud_etl/pipeline.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Оркестрация выборки снапшотов из SQLite и загрузки кадровых файлов 1С.
===============================================================================
"""
import os
import json
import logging
from typing import Optional, Dict, Any, Tuple
from typing import Optional
import pandas as pd
from core.connection import get_connection
from core.database import load_scud_from_db_by_snapshot
from config import DATA_DIR
from services.data_loader import load_staff_data, load_absent_data
logger = logging.getLogger("SCUD_PIPELINE")
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) -> Optional[pd.DataFrame]:
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
"""
Загружает наилучший срез СКУД за дату.
Если prefer_final_y=True — отдает предпочтение финишному Y (23:59:59).
Извлекает срез за дату из SQLite. Для отчета за вчера строго ищет
финальный вечерний срез Y (с зафиксированными выходами за 22:00:00).
"""
with get_connection() as conn:
with get_connection(row_factory=True) 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')
SELECT raw_data_json
FROM scud_snapshots
WHERE (snapshot_date = ? OR date_str = ?) AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '22:00%')
ORDER BY id DESC LIMIT 1
""", (date_str,))
""", (date_str, date_str))
row = cursor.fetchone()
if row:
target_snap_id = row[0]
if row and row["raw_data_json"]:
data = json.loads(row["raw_data_json"])
return pd.DataFrame(data)
if not target_snap_id:
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
SELECT raw_data_json
FROM scud_snapshots
WHERE snapshot_date = ? OR date_str = ?
ORDER BY id DESC LIMIT 1
""", (date_str,))
""", (date_str, date_str))
row = cursor.fetchone()
if row:
target_snap_id = row[0]
if row and row["raw_data_json"]:
data = json.loads(row["raw_data_json"])
return pd.DataFrame(data)
if not target_snap_id:
return None
return load_scud_from_db_by_snapshot(date_str, snapshot_param=target_snap_id)
return pd.DataFrame()
def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Загружает реестры штата и отсутствий 1С на указанную дату через data_loader.
Загружает файлы Штат_*.xlsx и Отсутствия_*.xlsx за указанную дату из data/1c/.
"""
df_staff = load_staff_data(date_str)
df_abs = load_absent_data(date_str)
return df_staff, df_abs
formatted_date = date_str.replace(".", "_")
staff_file = f"data/1c/Штат_{formatted_date}.xlsx"
absences_file = f"data/1c/Отсутствия_{formatted_date}.xlsx"
df_staff = pd.read_excel(staff_file) if os.path.exists(staff_file) else pd.DataFrame()
df_absences = pd.read_excel(absences_file) if os.path.exists(absences_file) else pd.DataFrame()
return df_staff, df_absences
+31 -82
View File
@@ -1,46 +1,40 @@
"""
===============================================================================
FILE: services/scud_etl/sql_queries.py
ROLE: Изолированные SQL-шаблоны для MS SQL Server (СКУД Орион Pro).
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Хранилище сырых SQL-шаблонов для выгрузки из MS SQL Server (СКУД Орион Pro).
AI-CONTEXT-ANCHORS:
- ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]: T-SQL запрос с расчетом первой активности и длительности.
===============================================================================
"""
# 1. Основной срез СКУД с учетом двухконтурного правого турникета
SQL_QUERY_TEMPLATE = r"""
# ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]
SCUD_EXPORT_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};
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})
)
)
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
),
Passages AS (
SELECT
@@ -48,21 +42,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 [Фирма],
@@ -84,8 +67,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
@@ -93,14 +80,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 [Находился_в_здании],
@@ -111,7 +98,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
@@ -127,41 +114,3 @@ WHERE
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
ORDER BY p.Name ASC;
"""
# 2. Сырые события физических проходов турникетов
SQL_RAW_EVENTS_QUERY = r"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME);
DECLARE @EndDate DATETIME = {end_datetime_sql};
SELECT
log.TimeVal,
log.HozOrgan,
LTRIM(RTRIM(
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
)) AS [Сотрудник],
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
log.Event,
log.Mode,
log.DoorIndex,
CASE
WHEN log.Mode = 2 THEN 'OUT'
WHEN log.Mode = 1 THEN 'IN'
WHEN log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
WHEN log.Event IN (1, 21, 26, 54, 64) THEN 'IN'
ELSE 'OTHER'
END AS Direction
FROM pLogData log WITH (NOLOCK)
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event IN (28, 32)
AND ISNULL(p.StatusRecord, 0) = 0
ORDER BY log.TimeVal ASC;
"""
-80
View File
@@ -1,80 +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_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.snapshots.finder import find_or_create_snapshot_for_time
from services.excel_exporter import generate_summary_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("SVODKA_GENERATOR")
def generate_svodka_service(
target_date: Optional[str] = None,
target_time: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует оперативную сводку на указанную дату / время:
- target_date: дата сводки (по умолчанию сегодня).
- target_time: время среза (например '14:30').
- snapshot_id: точный ID среза.
"""
date_clean = str(target_date or DATE_TODAY).replace('_', '.')
applied_note = ""
# Если передано время, но не указан конкретный snapshot_id — ищем ближайший или запрашиваем экспорт
if target_time and not snapshot_id:
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=True)
snapshot_id = found_id
applied_note = note
if note:
logger.info(note)
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Срез СКУД за {date_clean} ({applied_note or snapshot_id or 'последний доступный'}) не найден в базе."
}
df_staff, df_abs = load_1c_files_for_date(date_clean)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
metrics = calculate_summary_metrics(df_merged)
anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud)
# Добавляем суффикс времени в имя файла, если сводка строилась на точный срез
time_suffix = f" на {target_time.replace(':', '-')}" if target_time else ""
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "SVODKA",
"date": date_clean,
"target_time": target_time,
"snapshot_id": snapshot_id or "AUTO_LATEST",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"metrics": metrics,
"anomalies_count": len(anomalies),
"note": applied_note,
"message": f"Ежедневная сводка на {date_clean} {target_time or ''} успешно сформирована."
}
+83 -229
View File
@@ -1,10 +1,6 @@
"""
===============================================================================
FILE: services/scud_export.py
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
Корректная фильтрация транзитных проходов турникетов парковки и двора.
Учет только левого PERCo (DoorIndex = 1) и факта физического прохода (Event = 32).
===============================================================================
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel.
Добавлена фиксация Первой Активности (без учета направления) и среза по времени.
"""
import argparse
@@ -14,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")
@@ -39,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")
_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)
@@ -69,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"
@@ -80,36 +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})
)
)
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
),
Passages AS (
SELECT
@@ -117,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 [Фирма],
@@ -153,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
@@ -162,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 [Находился_в_здании],
@@ -180,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
@@ -197,100 +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
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event IN (28, 32)
AND ISNULL(p.StatusRecord, 0) = 0
ORDER BY log.TimeVal ASC;
"""
def 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)
@@ -299,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")
@@ -313,7 +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)
targets = get_targets(input_date, input_time)
targets = get_targets(input_date)
conn_str = (
f"DRIVER={{{ODBC_DRIVER}}};"
f"SERVER={SERVER_NAME};"
@@ -329,66 +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"]
is_yesterday = (period_label == "Вчера")
today_date = datetime.now().date()
is_past_day = (processing_date < today_date) or (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
# 1. Формируем фильтр правого турникета из exceptions_registry
exc_data = load_exceptions()
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]
conditions = []
if t_fios:
fio_in = ", ".join([f"N'{f}'" for f in t_fios])
# Склеиваем Фамилию + Имя + Отчество для точного сравнения с реестром ФИО
full_fio_sql = (
"LTRIM(RTRIM("
"ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') + "
"CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END + "
"CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END"
"))"
)
conditions.append(f"{full_fio_sql} IN ({fio_in})")
if t_depts:
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"
# 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
)
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:
@@ -406,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:
@@ -430,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")
@@ -447,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:
-89
View File
@@ -1,89 +0,0 @@
"""
===============================================================================
FILE: services/snapshots/finder.py
ROLE: Поиск ближайшего снапшота в SQLite (Smart Snap-to-Grid) и On-Demand экспорт.
===============================================================================
"""
import logging
from datetime import datetime
from typing import Optional, Tuple
from core.connection import get_connection
from services.scud_export import run_export
logger = logging.getLogger("SNAPSHOT_FINDER")
def find_or_create_snapshot_for_time(
target_date_str: str,
target_time_str: str,
tolerance_minutes: int = 20,
allow_ondemand_export: bool = True
) -> Tuple[Optional[str], str]:
"""
Ищет ближайший срез за указанную дату и время (±tolerance_minutes).
Если не найден и allow_ondemand_export=True — запрашивает выгрузку из MS SQL на это время.
Возвращает: (snapshot_id, human_message)
"""
date_clean = target_date_str.replace('_', '.')
dt_target = datetime.strptime(f"{date_clean} {target_time_str}", "%d.%m.%Y %H:%M")
# 1. Поиск существующих снапшотов за эту дату в SQLite
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT DISTINCT snapshot_id, snapshot_time
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
""", (date_clean,))
rows = cursor.fetchall()
best_snapshot = None
min_diff_seconds = float('inf')
for snap_id, snap_time_str in rows:
try:
# Формат в базе: YYYY-MM-DD HH:MM:SS или DD.MM.YYYY HH:MM:SS
raw_time = str(snap_time_str).strip()
if '.' in raw_time.split()[0]:
dt_snap = datetime.strptime(raw_time, "%d.%m.%Y %H:%M:%S")
else:
dt_snap = datetime.strptime(raw_time, "%Y-%m-%d %H:%M:%S")
diff = abs((dt_snap - dt_target).total_seconds())
if diff < min_diff_seconds:
min_diff_seconds = diff
best_snapshot = (snap_id, snap_time_str, dt_snap)
except Exception:
continue
# Если найден срез в пределах допуска (по умолчанию 20 минут)
if best_snapshot and min_diff_seconds <= (tolerance_minutes * 60):
snap_id, snap_time, dt_s = best_snapshot
diff_mins = round(min_diff_seconds / 60)
return snap_id, f"Использован готовый срез {snap_id} за {dt_s.strftime('%H:%M')} (разница {diff_mins} мин)."
# 2. Если срез не найден и разрешен On-Demand экспорт из MS SQL Орион
if allow_ondemand_export:
logger.info(f"Снапшот на {date_clean} {target_time_str} не найден в SQLite. Запуск прямого среза из MS SQL...")
run_export(
input_date=date_clean,
debug=False,
save_xlsx=True
)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
ORDER BY id DESC LIMIT 1
""", (date_clean,))
row = cursor.fetchone()
if row:
return row[0], f"Создан новый срез {row[0]} из MS SQL на {target_time_str}."
return None, f"Срез на {date_clean} {target_time_str} не найден."
-112
View File
@@ -1,112 +0,0 @@
"""
===============================================================================
FILE: services/snapshots/retention.py
ROLE: Политика ротации и очистки промежуточных почасовых срезов в SQLite.
===============================================================================
"""
import logging
from datetime import datetime, timedelta
from typing import List, Tuple
from core.connection import get_connection
logger = logging.getLogger("SNAPSHOT_RETENTION")
def cleanup_old_intermediate_snapshots(days_to_keep_all: int = 2) -> int:
"""
Очищает промежуточные дневные срезы старше days_to_keep_all дней.
Правило сохранения для архивных дней:
- Сохраняется срез Y (23:59:59).
- Сохраняется полуденный срез (ближайший к 13:00).
- Все остальные промежуточные снапшоты удаляются из scud_logs.
Возвращает количество удаленных записей.
"""
cutoff_date = datetime.now() - timedelta(days=days_to_keep_all)
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
deleted_rows_count = 0
with get_connection() as conn:
cursor = conn.cursor()
# 1. Получаем список всех уникальных дат старше cutoff_date
cursor.execute("""
SELECT DISTINCT log_date
FROM scud_logs
WHERE snapshot_time IS NOT NULL
""")
all_dates = [row[0] for row in cursor.fetchall()]
for raw_date in all_dates:
try:
# Преобразуем формат ДД.ММ.ГГГГ в объект даты
d_clean = raw_date.replace('_', '.')
dt_obj = datetime.strptime(d_clean, "%d.%m.%Y")
if dt_obj >= cutoff_date:
continue # Свежие дни не трогаем — там хранятся все срезы
# 2. Для архивной даты ищем все снапшоты
cursor.execute("""
SELECT DISTINCT snapshot_id, snapshot_time
FROM scud_logs
WHERE log_date = ?
""", (raw_date,))
snapshots = cursor.fetchall()
if len(snapshots) <= 2:
continue # Если и так 1-2 среза, ничего чистить не нужно
keep_ids = set()
# Ищем финальный срез Y / 23:59:59 / 22:00:00
for s_id, s_time in snapshots:
s_id_str = str(s_id)
s_time_str = str(s_time)
if s_id_str.startswith('Y') or '23:59:59' in s_time_str or '22:00:00' in s_time_str:
keep_ids.add(s_id)
break
# Ищем полуденный срез (ближайший к 13:00)
noon_target = datetime.strptime(f"{d_clean} 13:00:00", "%d.%m.%Y %H:%M:%S")
best_noon_id = None
min_noon_diff = float('inf')
for s_id, s_time in snapshots:
if s_id in keep_ids:
continue
try:
raw_t = str(s_time).strip()
if '.' in raw_t.split()[0]:
dt_s = datetime.strptime(raw_t, "%d.%m.%Y %H:%M:%S")
else:
dt_s = datetime.strptime(raw_t, "%Y-%m-%d %H:%M:%S")
diff = abs((dt_s - noon_target).total_seconds())
if diff < min_noon_diff:
min_noon_diff = diff
best_noon_id = s_id
except Exception:
continue
if best_noon_id:
keep_ids.add(best_noon_id)
# 3. Все снапшоты, не попавшие в keep_ids, удаляем
to_delete_ids = [s_id for s_id, _ in snapshots if s_id not in keep_ids]
for del_id in to_delete_ids:
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (del_id,))
deleted_rows_count += cursor.rowcount
logger.info(f"Удален промежуточный архивный срез {del_id} за {raw_date}")
except Exception as e:
logger.warning(f"Ошибка при обработке ротации за дату {raw_date}: {e}")
conn.commit()
if deleted_rows_count > 0:
logger.info(f"Ротация завершена: удалено {deleted_rows_count} строк промежуточных срезов.")
return deleted_rows_count
+12 -31
View File
@@ -3,7 +3,7 @@
FILE: services/snapshots/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / snapshots
ROLE: Бизнес-логика срезов СКУД (выборка, отображение времени, удаление).
ROLE: Бизнес-логика срезов СКУД (выборка, валидация Y-срезов, удаление).
AI-CONTEXT-ANCHORS:
- ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов.
@@ -18,39 +18,20 @@ from core.repositories.scud_repo import get_available_snapshots, delete_snapshot
# ANCHOR[SNAPSHOT_GET_REGISTRY]
def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]:
"""
Возвращает реестр снапшотов.
В поле snapshot_time объединяет время среза и фактическое время создания снапшота.
"""
"""Возвращает реестр снапшотов за дату или за все доступные дни."""
clean_date = date_str.strip() if date_str else ""
rows = get_available_snapshots(date_str=clean_date if clean_date else None)
snapshots = []
for r in rows:
snap_id = r[0]
log_date = r[1]
snap_time = r[2]
rec_count = r[3]
created_at = r[4] if len(r) > 4 else None
slice_time_str = snap_time.split()[1] if snap_time and " " in snap_time else snap_time
created_str = ""
if created_at and " " in str(created_at):
c_date, c_time = str(created_at).split()[:2]
c_parts = c_date.split("-")
c_date_fmt = f"{c_parts[2]}.{c_parts[1]}" if len(c_parts) == 3 else c_date
created_str = f" · создан {c_date_fmt} {c_time[:5]}"
display_label = f"Срез {slice_time_str}{created_str}"
snapshots.append({
"snapshot_id": snap_id,
"log_date": log_date,
"snapshot_time": display_label,
"record_count": rec_count,
"is_final": str(snap_id).startswith("Y") or "_FINAL" in str(snap_id)
})
snapshots = [
{
"snapshot_id": r[0],
"log_date": r[1],
"snapshot_time": r[2],
"record_count": r[3],
"is_final": str(r[0]).startswith("Y")
}
for r in rows
]
return {
"query_date": clean_date or "все",
+75 -46
View File
@@ -3,70 +3,99 @@
FILE: services/tasks/exporter.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / tasks
ROLE: Экспорт задач в чистый Markdown и генерация прямой ссылки на скачивание.
ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP).
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами.
===============================================================================
"""
import os
import uuid
from typing import Dict, Any, Optional
from config import BASE_DIR
import logging
from datetime import datetime
from typing import Dict, Any, Optional, List
from config import OUTPUT_DIR
from .repository import repo_get_tasks
WEB_OUTPUT_DIR = os.path.join(BASE_DIR, "output", "web", "db_export_tasks_markdown")
logger = logging.getLogger("TASK_EXPORTER")
def export_tasks_to_markdown(
user_id: int,
filename: Optional[str] = "ROADMAP.md",
status_filter: Optional[str] = None
) -> Dict[str, Any]:
tasks = repo_get_tasks(user_id=user_id, status=status_filter)
safe_filename = os.path.basename(filename or "ROADMAP.md")
if not safe_filename.endswith(".md"):
safe_filename += ".md"
# ANCHOR[TASK_EXPORT_MARKDOWN]
def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]:
"""Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/."""
tasks = repo_get_tasks(user_id)
if not tasks:
return {"status": "error", "message": "Список задач пуст, экспорт отменен"}
session_uuid = str(uuid.uuid4())[:8]
target_dir = os.path.join(WEB_OUTPUT_DIR, session_uuid)
os.makedirs(target_dir, exist_ok=True)
target_filepath = os.path.join(target_dir, safe_filename)
# 1. Фильтрация задач по статусу
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"]]
lines = [
f"# 📋 Реестр задач проекта ({safe_filename})",
f"**Всего задач:** {len(tasks)} ",
f"**Пользователь ID:** {user_id} ",
"",
"| ID | Статус | Приоритет | Модуль | Срок | Задача |",
"| :--- | :--- | :--- | :--- | :--- | :--- |"
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_str = datetime.now().strftime("%Y-%m-%d %H:%M")
# 2. Группировка по модулям
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"
]
status_icons = {
"IN_PROGRESS": "⚙️ В работе",
"COMPLETED": "✓ Завершено",
"BACKLOG": "📋 Бэклог"
}
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"]
for t in tasks:
t_id = t.get("task_id") or f"#{t.get('id')}"
t_status = status_icons.get(t.get("status"), t.get("status", "BACKLOG"))
t_prio = t.get("priority", "MEDIUM")
t_mod = t.get("module", "general")
t_due = t.get("due_date") or "—"
t_title = str(t.get("title", "")).replace("|", "\\|").strip()
lines.append(f"| `{t_id}` | {t_status} | {t_prio} | `{t_mod}` | {t_due} | {t_title} |")
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 "")
lines.append("")
content = "\n".join(lines)
md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}")
with open(target_filepath, "w", encoding="utf-8") as f:
md_lines.append("\n---\n")
content = "\n".join(md_lines)
# 3. Сохранение файла в изолированную сессионную папку
tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export")
os.makedirs(tool_dir, exist_ok=True)
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)
download_url = f"/api/v1/files/download/db_export_tasks_markdown/{session_uuid}/{safe_filename}"
return {
"status": "success",
"message": f"Отчет успешно сформирован в файл `{safe_filename}` (всего задач: {len(tasks)}).",
"filename": safe_filename,
"download_url": download_url,
"tasks_count": len(tasks)
"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)})."
}
+37 -11
View File
@@ -1,6 +1,15 @@
"""
===============================================================================
FILE: services/tasks/repository.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / tasks
ROLE: Низкоуровневые операции к таблице tasks в SQLite (CRUD).
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_REPO_GET]: Выборка задач с фильтрацией по статусу и пользователю.
- ANCHOR[TASK_REPO_ADD]: Вставка новой задачи со сквозным ID.
- ANCHOR[TASK_REPO_UPDATE]: Обновление реквизитов и статуса задачи.
- ANCHOR[TASK_REPO_DELETE]: Удаление задачи по числовому или строковому ID.
===============================================================================
"""
@@ -10,6 +19,7 @@ from core.connection import get_connection
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("#", "")
@@ -19,9 +29,12 @@ def normalize_task_id(task_id_input: str) -> str:
return f"TASK-{clean_id}"
# ANCHOR[TASK_REPO_GET]
def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
with get_connection(row_factory=True) as conn:
"""Получает список задач пользователя с опциональной фильтрацией по статусу."""
conn = get_connection(row_factory=True)
cursor = conn.cursor()
if status and status.upper() != "ALL":
target_status = status.upper()
if target_status in ["PROGRESS", "В РАБОТЕ"]:
@@ -46,9 +59,11 @@ def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str,
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
# ANCHOR[TASK_REPO_ADD]
def repo_add_task(
user_id: int,
module: str,
@@ -57,6 +72,13 @@ def repo_add_task(
due_date: Optional[str] = None,
status: str = "BACKLOG"
) -> Dict[str, Any]:
"""Добавляет новую задачу в SQLite с автогенерацией порядкового TASK-ID."""
conn = get_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"
@@ -65,20 +87,16 @@ def repo_add_task(
elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]:
target_status = "BACKLOG"
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT MAX(id) FROM tasks")
max_id = cursor.fetchone()[0] or 0
new_task_id = f"TASK-{(max_id + 1):02d}"
cursor.execute("""
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (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, "id": max_id + 1}
# ANCHOR[TASK_REPO_UPDATE]
def repo_update_task(
user_id: int,
task_id: str,
@@ -87,6 +105,10 @@ def repo_update_task(
status: Optional[str] = None,
due_date: Optional[str] = None
) -> Dict[str, Any]:
"""Комплексное обновление атрибутов задачи."""
conn = get_connection()
cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id)
@@ -117,6 +139,7 @@ def repo_update_task(
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])
@@ -125,11 +148,10 @@ def repo_update_task(
SET {', '.join(updates)}
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(sql, params)
rows_affected = cursor.rowcount
conn.commit()
conn.close()
if rows_affected == 0:
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
@@ -137,18 +159,22 @@ def repo_update_task(
return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"}
# ANCHOR[TASK_REPO_DELETE]
def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
"""Удаляет задачу по номеру ID."""
conn = get_connection()
cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id)
with get_connection() as conn:
cursor = conn.cursor()
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))
deleted = cursor.rowcount
conn.commit()
conn.close()
if deleted == 0:
return {"error": f"Задача {task_id} не найдена"}
+60 -53
View File
@@ -5,75 +5,85 @@ from services.ai_verifier import ask_ollama
from services.knowledge_base import load_knowledge_base
def find_ai_identity_suggestions(unexplained_df, raw_staff_df):
def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
"""
⭐️ Теневой ИИ-арбитраж: ищет потенциальные опечатки операторов СКУД
среди нераспознанных сотрудников и формирует рекомендации для администратора.
Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ).
Сравнивает фамилию и полное имя с порогом сходства >= 0.75.
"""
if unexplained_df.empty or raw_staff_df is None or raw_staff_df.empty:
if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty:
return []
staff_fios = raw_staff_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in raw_staff_df.columns else []
unexp_fios = unexplained_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in unexplained_df.columns else []
absent_dict = {}
for idx, r in raw_absent_df.iterrows():
raw_fio = str(r.get('ФИО', ''))
clean_fio = str(r.get('fio_clean', ''))
reason = str(r.get('Вид_отсутствия', ''))
if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']:
absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason}
suggestions = []
for scud_fio in unexp_fios:
f_parts = scud_fio.split()
if not f_parts:
matches = []
unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else []
for fio in unexplained_fios:
fio_parts = fio.split()
if not fio_parts:
continue
surname = f_parts[0].lower()
surname = fio_parts[0].lower()
for staff_fio in staff_fios:
if staff_fio.lower().startswith(surname[:4]):
ratio = difflib.SequenceMatcher(None, scud_fio.lower(), staff_fio.lower()).ratio()
if 0.75 <= ratio < 1.0:
suggestions.append({
"scud_fio": scud_fio,
"suggested_zup_fio": staff_fio,
"confidence": round(ratio, 2)
for abs_clean, abs_info in absent_dict.items():
abs_parts = abs_clean.split()
if not abs_parts:
continue
abs_surname = abs_parts[0].lower()
if surname == abs_surname:
ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio()
if ratio >= 0.75:
matches.append({
"target_fio": fio,
"found_in_1c_raw": abs_info['raw_fio'],
"reason_1c": abs_info['reason']
})
break
return suggestions
return matches
def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"):
# 1. Загрузка Базы Знаний
kb = load_knowledge_base()
custom_rules = kb.get("rules", [])
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
# 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff)
total_staff = len(merged_df)
present_office_cnt = len(merged_df[merged_df['Пришел'] == True])
reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False)
remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason])
explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))])
is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
)
present_cnt = len(merged_df[is_working_mask])
explained_cnt = len(absent_explained)
unexplained_cnt = len(absent_unexplained)
anomalies_cnt = len(anomalies_list) if anomalies_list else 0
# ⭐️ Теневые подсказки ИИ
ai_suggestions = find_ai_identity_suggestions(absent_unexplained, raw_staff_df)
anomalies_formatted = []
if anomalies_list:
for idx, a in enumerate(anomalies_list, 1):
anomalies_formatted.append(f"{idx}. {a.get('fio', 'Сотрудник')}: {a.get('description', '')}")
anomalies_text_block = "\n".join(anomalies_formatted) if anomalies_formatted else "Аномалий не обнаружено."
# 3. Точный расчет неточных совпадений через Python
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
# 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД
unexplained_list = []
if not absent_unexplained.empty:
for idx, r in absent_unexplained.iterrows():
fio = r.get('Сотрудник', r.get('fio_clean', ''))
dept_scud = str(r.get('department_scud', '')).strip()
dept_1c = str(r.get('Подразделение', '')).strip()
dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c
if not dept:
dept = '—'
pos = r.get('Должность', '—')
unexplained_list.append(f"{fio} — {dept}, {pos}")
# 5. Сборка промпта для Ollama
prompt = f"""
Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
Сформируй итоговую сводку кадрового контроля на {date_str}.
@@ -83,53 +93,50 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
🚨 ВХОДНЫЕ МЕТРИКИ:
- Всего сотрудников: {total_staff}
- Итого на работе (в офисе): {present_office_cnt}
- В том числе на удаленной работе: {remote_home_cnt}
- Работают (офис / удаленка / командировки): {present_cnt}
- Официально отсутствуют: {explained_cnt}
- Неизвестно (истинно неотмеченные): {unexplained_cnt}
- Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
---
🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
{anomalies_text_block}
{json.dumps(anomalies_list, ensure_ascii=False, indent=2)}
---
💡 ПРЕДЛОЖЕНИЯ ИИ ПО СОПОСТАВЛЕНИЮ ФИО ({len(ai_suggestions)} шт):
{json.dumps(ai_suggestions, ensure_ascii=False, indent=2)}
⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт):
{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)}
---
📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел):
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
СТРОГИЕ ИНСТРУКЦИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ".
2. В разделе "Подозрения на ошибки сопоставления ФИО" выведи рекомендации по сопоставлению из массива "ПРЕДЛОЖЕНИЯ ИИ". Если массив пуст — напиши "Ошибок сопоставления ФИО не обнаружено."
3. В разделе "Неизвестные случаи" выведи нумерованный список всех {unexplained_cnt} человек.
4. В разделе "Рекомендации" дай 2-3 системные рекомендации.
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций.
СТРОГИЙ ШАБЛОН ОТВЕТА:
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
- Всего офисных сотрудников: **{total_staff}**
- Итого на работе (в офисе): **{present_office_cnt}**
- В том числе на удаленной работе: **{remote_home_cnt}**
- Работают (офис / удаленка / командировки): **{present_cnt}**
- Официально отсутствуют: **{explained_cnt}**
- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
(Список аномалий или 'Аномалий не обнаружено.')
(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.")
#### 💡 Подозрения на ошибки сопоставления ФИО и предложения ИИ:
(Список предложений сопоставления или 'Ошибок сопоставления ФИО не обнаружено.')
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.")
#### Неизвестные случаи: {unexplained_cnt}
(Нумерованный список всех {unexplained_cnt} человек)
(Выведи нумерованный список всех {unexplained_cnt} человек)
### Точечные рекомендации:
(2-3 системные рекомендации)
(2-3 системные рекомендации без указания ФИО сотрудников)
"""
sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке форматированным текстом. Запрещено выводить фигурные скобки JSON."
sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке по предоставленному Markdown-шаблону. Категорически запрещено выводить JSON или китайские символы."
return ask_ollama(prompt, system_prompt=sys_prompt)
View File
+49
View File
@@ -0,0 +1,49 @@
"""
===============================================================================
FILE: services/text_reporter/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / text_reporter
ROLE: Формирование текстовой сводки ИИ-аудитора для вывода в консоль и сохранения в MD.
===============================================================================
"""
from typing import Dict, Any, List
def format_controlling_summary_markdown(
target_date: str,
metrics: Dict[str, Any],
anomalies: List[Dict[str, Any]]
) -> str:
"""Генерирует Markdown-текст ежедневной сводки контроллинга."""
lines = [
f"**Сводка контроллинга СКУД и 1С:ЗУП на {target_date}**\n",
f"- Всего сотрудников по штату: **{metrics['total_staff']}**",
f"- Итого на работе (в офисе): **{metrics['working_in_office_count']}**",
f"- Удаленная работа (из дома): **{metrics['remote_home_count']}**",
f"- Официально отсутствуют: **{metrics['official_absent_count']}**",
f"- Неизвестно (истинно неотмеченные): **{metrics['unknown_count']}** чел.",
f"- Выявлено аномалий/конфликтов реестров: **{len(anomalies)}** шт.\n"
]
# Блок аномалий
lines.append(f"#### 🚨 Выявленные ИИ аномалии и конфликты источников ({len(anomalies)}):")
if anomalies:
for idx, a in enumerate(anomalies, 1):
lines.append(f"{idx}. {a['fio']}: {a['description']}")
else:
lines.append("Конфликтов и аномалий реестров не обнаружено.")
lines.append("")
# Блок неизвестных
unknown_list = metrics.get("unknown_list", [])
lines.append(f"#### ❓ Неизвестные случаи ({len(unknown_list)}):")
if unknown_list:
for idx, u in enumerate(unknown_list, 1):
dept = u.get("Подразделение") or "—"
pos = u.get("Должность") or "—"
lines.append(f"{idx}. {u.get('fio_clean')} — {dept}, {pos}")
else:
lines.append("Все отсутствия подтверждены документами.")
return "\n".join(lines)
+1 -1
View File
@@ -87,7 +87,7 @@ def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame:
try:
conn_str = get_zup_connection_string()
with pyodbc.connect(conn_str, timeout=30) as conn:
with pyodbc.connect(conn_str, timeout=5) as conn:
df = pd.read_sql(query, conn, params=[target_date])
if not df.empty:
File diff suppressed because it is too large Load Diff