33 changed files with 1572 additions and 4876 deletions
+19 -29
View File
@@ -8,11 +8,11 @@ DATA_DIR = os.path.join(BASE_DIR, "data")
SCUD_DIR = os.path.join(DATA_DIR, "scud") SCUD_DIR = os.path.join(DATA_DIR, "scud")
ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") ZUP_1C_DIR = os.path.join(DATA_DIR, "1c")
OUTPUT_DIR = os.path.join(BASE_DIR, "output") 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" 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) os.makedirs(folder, exist_ok=True)
NOW = datetime.now() NOW = datetime.now()
@@ -23,30 +23,32 @@ if NOW.weekday() == 0:
else: else:
DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
OLLAMA_URL = "http://10.121.17.227:11434/api/generate" # --- Настройки LLM / Ollama ---
OLLAMA_MODEL = "qwen2.5:14b-instruct-q8_0" OLLAMA_URL = "http://192.168.11.3:11434/api/generate"
OLLAMA_MODEL = "qwen2.5:14b"
MODEL_NAME = OLLAMA_MODEL MODEL_NAME = OLLAMA_MODEL
KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json")
EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json")
# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) ---
ZUP_SQL_CONFIG = { 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"), "server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"),
"database": os.getenv("ZUP_SQL_DB", "ZUP30"), "database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01
"user": os.getenv("ZUP_SQL_USER", "scud_reader"), "user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server
"password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server
"trust_server_certificate": "yes", "trust_server_certificate": "yes", # Доверять сертификату сервера
"encrypt": "no" "encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование
} }
def find_dated_file(prefix, date_str, search_dirs=None): def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]):
if search_dirs is None: """
search_dirs = [ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."] Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026).
"""
date_dots = str(date_str).replace('_', '.') date_dots = date_str
date_underscores = date_dots.replace('.', '_') date_underscores = date_str.replace('.', '_')
for d in search_dirs: for d in search_dirs:
if not os.path.exists(d): if not os.path.exists(d):
@@ -73,18 +75,6 @@ def clean_scud_fio_light(fio_str):
def load_exceptions(): 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 import json
if os.path.exists(EXCEPTIONS_PATH): if os.path.exists(EXCEPTIONS_PATH):
try: try:
@@ -92,4 +82,4 @@ def load_exceptions():
return json.load(f) return json.load(f)
except Exception: except Exception:
pass pass
return {"fio": [], "departments": [], "positions": [], "position_keywords": [], "include_fio": []} return {"fio": [], "departments": [], "positions": [], "position_keywords": []}
+7 -35
View File
@@ -134,55 +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: def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> pd.DataFrame:
with get_connection() as conn: with get_connection() as conn:
df = pd.DataFrame() df = pd.DataFrame()
# 1. Если передан конкретный ID снапшота (например 'Y20260820-004')
if snapshot_param: if snapshot_param:
df = pd.read_sql_query( df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(str(snapshot_param),))
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(str(snapshot_param),)
)
# 2. Если ищем за дату (для вчерашнего дня строго ищем Y-снапшот)
if df.empty and date_str: if df.empty and date_str:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", (date_str,))
# ⭐️ Жесткий приоритет 1: Ищем снапшот с префиксом 'Y'
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY snapshot_time DESC, id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone() row = cursor.fetchone()
# Приоритет 2: Если Y нет (например, за сегодня), берем самый свежий по времени
if not row: if not row:
cursor.execute( cursor.execute("SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", (date_str,))
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone() row = cursor.fetchone()
if row and row[0]: if row and row[0]:
target_id = row[0] df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(target_id,)
)
if not df.empty: if not df.empty:
rename_map = { rename_map = {
'department': 'department_scud', 'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник',
'position': 'Должность', 'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня',
'fio': 'Сотрудник', 'duration': 'Находился_в_здании', 'is_present': 'Пришел'
'time_in': 'Начало_дня',
'first_activity': 'Первая_активность',
'time_out': 'Конец_дня',
'time_in_building': 'Находился_в_здании',
'is_present': 'Пришел'
} }
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}) 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']: for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']:
if col not in df.columns: if col not in df.columns:
df[col] = False if col == 'Пришел' else '—' df[col] = False if col == 'Пришел' else '—'
-11
View File
@@ -127,17 +127,6 @@ def init_all_tables() -> None:
); );
""") """)
cursor.execute("""
CREATE TABLE IF NOT EXISTS exceptions_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL, -- 'department', 'position', 'fio', 'position_keyword', 'include_fio', 'remote_worker'
value TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category, value)
);
""")
# 5. Задачи # 5. Задачи
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
Binary file not shown.
Binary file not shown.
Binary file not shown.
-3
View File
@@ -11,6 +11,3 @@ fio,reason,note
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка
Чуркина Елена Геннадьевна,Удаленная работа,Постоянная удаленка
Пушков Александр Александрович,Удаленная работа,Временная удаленка с 26.08.2026 по 04.09.2026
Сысоев Алексей Валерьевич,Удаленная работа,Временная удаленка с 25.08.2026 по 27.08.2026
1 fio reason note
11 Ковалев Владимир Владимирович Удаленная работа Постоянная удаленка
12 Кожокарь Татьяна Юрьевна Удаленная работа Постоянная удаленка
13 Субетто Юлия Викторовна Удаленная работа Постоянная удаленка
Чуркина Елена Геннадьевна Удаленная работа Постоянная удаленка
Пушков Александр Александрович Удаленная работа Временная удаленка с 26.08.2026 по 04.09.2026
Сысоев Алексей Валерьевич Удаленная работа Временная удаленка с 25.08.2026 по 27.08.2026
-3752
View File
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -7,9 +7,10 @@
"Уборщик служебных помещений" "Уборщик служебных помещений"
], ],
"fio": [ "fio": [
"Таткало Валерий Валерьевич", "Таткало Валерий Валерьевич",
"Петренюк Андрей Германович", "Петренюк Андрей Германович",
"Михалев Сергей Геннадьевич" "Чуркина Елена Геннадьевна",
"Михалев Сергей Геннадьевич"
], ],
"position_keywords": [ "position_keywords": [
"уборщик", "уборщик",
@@ -17,9 +18,5 @@
"дворник", "дворник",
"гардероб", "гардероб",
"рабочий по обслуживанию" "рабочий по обслуживанию"
],
"include_fio": [
"Тарасенко Александр Александрович",
"Журиков Михаил Николаевич"
] ]
} }
+17 -53
View File
@@ -8,55 +8,38 @@ ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1
import os import os
import sys import sys
import argparse
import logging import logging
import pandas as pd
from datetime import datetime, timedelta 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.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.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.text_reporter import generate_markdown_report from services.text_reporter.service import format_controlling_summary_markdown
from services.scud_export import run_export # Корректные импорты из папки services/
from services.share_copier import copy_1c_files_from_share from services.scud_export import run_scud_export_today_and_yesterday
from services.excel_exporter import generate_summary_excel, generate_detailed_excel 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") logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
def main(): def main():
parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С")
parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки")
parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL")
parser.add_argument("--snapshot", type=str, default=None, help="ID снапшота для расчета")
args = parser.parse_args()
print("=" * 60) print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}") print("ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С")
print("=" * 60) print("=" * 60)
now = datetime.now() now = datetime.now()
today_str = now.strftime("%d.%m.%Y") today_str = now.strftime("%d.%m.%Y")
# ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня) yesterday_str = (now - timedelta(days=1)).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] Выгрузка свежих данных СКУД # [Этап 0] Выгрузка свежих данных СКУД
if not args.skip_export and not args.snapshot: print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...") run_scud_export_today_and_yesterday()
run_export(debug=args.debug)
else:
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
# [Этап 0.5] Синхронизация файлов с шары 1С # [Этап 0.5] Синхронизация файлов с шары 1С
if not args.snapshot and not args.skip_export: print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...") sync_1c_files_from_share()
copy_1c_files_from_share()
else:
print("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...")
# [Этап 1-2] Загрузка данных # [Этап 1-2] Загрузка данных
print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...") print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...")
@@ -66,40 +49,21 @@ def main():
df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str) 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) df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
# [Этап 3] Детальный отчет за вчера # [Этап 3] Детальный отчет за вчера (на базе финального Y-снапшота)
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday) df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str) build_detailed_yesterday_excel(df_merged_yesterday, yesterday_str)
# [Этап 4] Сводка за сегодня # [Этап 4] Сводка за сегодня
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...")
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
metrics_today = calculate_summary_metrics(df_merged_today) metrics_today = calculate_summary_metrics(df_merged_today)
anomalies_today = detect_registry_anomalies(df_merged_today) anomalies_today = detect_registry_anomalies(df_merged_today)
generate_summary_excel(df_merged_today, date_str=today_str) build_daily_summary_excel(df_merged_today, metrics_today, today_str)
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора # [Этап 5] Формирование текстового отчета и вывод в консоль
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...") print(f"\n[5/5] Формирование Markdown-сводки...")
summary_md = format_controlling_summary_markdown(today_str, metrics_today, anomalies_today)
absent_explained = df_merged_today[
(df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].notna()) &
(~df_merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_unexplained = df_merged_today[
(df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(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,
date_str=today_str
)
os.makedirs("output", exist_ok=True) os.makedirs("output", exist_ok=True)
md_file_path = f"output/Сводка_контроллинга_{today_str}.md" md_file_path = f"output/Сводка_контроллинга_{today_str}.md"
+2 -2
View File
@@ -14,8 +14,8 @@ from typing import Dict, Any, Optional
logger = logging.getLogger("OLLAMA_CLIENT") 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-instruct-q8_0" TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0" VISION_MODEL = "qwen2.5vl:7b-q8_0"
LLM_OPTIONS = { LLM_OPTIONS = {
+75 -79
View File
@@ -1,97 +1,93 @@
""" """
=============================================================================== ===============================================================================
FILE: modules/web_api/routers/chat.py FILE: modules/web_api/routers/chat.py
ROLE: Обработка сообщений веб-чата с поддержкой токенов и гостевого доступа. PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers
ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков.
=============================================================================== ===============================================================================
""" """
from fastapi import APIRouter, Header, HTTPException, Request # ANCHOR[CHAT_ROUTER_IMPORTS]
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List, Dict, Any
import logging
from services.text_reporter import ask_ollama from .auth import get_current_user
from services.knowledge_base import load_knowledge_base from modules.ai_engine.agent import process_chat_message
from core.database import get_connection 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/chat", tags=["chat"])
router = APIRouter(prefix="/api/v1", tags=["Chat"])
class ChatMessageRequest(BaseModel): # ANCHOR[DRAFT_SCHEMA]
message: str class UpdateDraftRequest(BaseModel):
session_id: Optional[str] = "web_session_main" session_id: str
user_id: Optional[int] = 1 draft_text: str
def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int: # ANCHOR[CHAT_ENDPOINTS]
""" @router.post("")
Извлекает ID пользователя из Bearer-токена. async def chat_endpoint(
Если токен не передан или сессия новая — использует user_id=1 по умолчанию, session_id: str = Form("web_session_main"),
не блокируя работу ошибкой 403 Forbidden. message: str = Form(""),
""" file: Optional[UploadFile] = File(default=None),
if explicit_user_id and explicit_user_id > 0: current_user: Dict[str, Any] = Depends(get_current_user)
return explicit_user_id ):
"""Диалог авторизованного пользователя с агентом."""
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)
if authorization and authorization.startswith("Bearer "): reply, history, action_type = process_chat_message(
token = authorization.replace("Bearer ", "").strip() user_id=current_user["id"],
# Если используется простой токен вида 'user_1' или JWT user_message=message,
if token.isdigit(): file_context=parsed_file["text"],
return int(token) image_b64=parsed_file["image_b64"],
elif token.startswith("dev_token_"): session_id=session_id
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}")
# Загружаем контекст базы знаний
kb = load_knowledge_base()
rules_text = "\n".join([f"- {r}" for r in kb.get("rules", [])])
system_prompt = (
"Ты — ИИ-ассистент системы кадровой безопасности и контроллинга СКУД Orion AI.\n"
"Отвечай четко, профессионально и на русском языке.\n"
f"Актуальные правила системы:\n{rules_text}"
) )
return {"reply": reply, "history": history, "action_type": action_type}
try:
reply_text = ask_ollama(user_msg, system_prompt=system_prompt)
# Сохранение истории в SQLite при необходимости @router.post("/guest")
try: async def guest_chat_endpoint(
with get_connection() as conn: session_id: str = Form("web_session_main"),
conn.execute( message: str = Form(""),
"INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?), (?, ?, ?)", file: Optional[UploadFile] = File(default=None)
(session_id, "user", user_msg, session_id, "assistant", reply_text) ):
) """Гостевой диалог (user_id=0)."""
conn.commit() parsed_file = {"text": "", "image_b64": None}
except Exception: if file and file.filename:
pass file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
return { reply, history, action_type = process_chat_message(
"status": "success", user_id=0,
"user_id": user_id, user_message=message,
"session_id": session_id, file_context=parsed_file["text"],
"response": reply_text image_b64=parsed_file["image_b64"],
} session_id=session_id
except Exception as e: )
logger.error(f"Ошибка вызова нейросети: {e}") return {"reply": reply, "history": history, "action_type": action_type}
return {
"status": "error",
"response": f"⚠️ Ошибка обработки запроса: {str(e)}" @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": "Черновик успешно обновлен в сессии"}
-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"}
+303 -57
View File
@@ -1,62 +1,308 @@
/** function showAuthModal() {
* =============================================================================== const el = document.getElementById("auth-modal");
* FILE: static/js/auth.js if (el) el.classList.remove("hidden");
* ROLE: Управление сессией пользователя, токенами и корректным выходом (Logout). }
* ===============================================================================
*/
const AuthManager = { function hideAuthModal() {
getToken() { const el = document.getElementById("auth-modal");
let token = localStorage.getItem("auth_token") || localStorage.getItem("token"); if (el) el.classList.add("hidden");
if (!token) { }
// Если токена нет — инициализируем рабочий дефолтный токен
token = "dev_token_1"; async function handleLogin(e) {
localStorage.setItem("auth_token", token); if (e && e.preventDefault) e.preventDefault();
localStorage.setItem("user_id", "1");
const usernameInput = document.getElementById("auth-username-input");
const passwordInput = document.getElementById("auth-password-input");
const errorEl = document.getElementById("auth-error");
if (!usernameInput || !passwordInput) return;
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username || !password) return;
if (errorEl) errorEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.status === 200) {
// Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
API_TOKEN = data.token;
CURRENT_USERNAME = data.username;
IS_ADMIN = data.is_admin;
IS_GUEST = false;
localStorage.setItem("scud_api_auth_token", data.token);
localStorage.setItem("scud_username", data.username);
localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
localStorage.removeItem("scud_is_guest");
hideAuthModal();
updateUIState();
if (typeof loadTasks === 'function') {
loadTasks();
}
} else {
if (errorEl) {
errorEl.innerText = data.detail || "Ошибка авторизации";
errorEl.classList.remove("hidden");
}
}
} catch (err) {
console.error("[Auth Error]", err);
if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
} }
return token;
},
getUserId() {
return parseInt(localStorage.getItem("user_id") || "1", 10);
},
logout() {
console.log("[Auth] Выполняется выход из учетной записи...");
// 1. Полная очистка хранилищ браузера
localStorage.removeItem("auth_token");
localStorage.removeItem("token");
localStorage.removeItem("user_id");
sessionStorage.clear();
// 2. Сброс авторизационных cookies (если присутствуют)
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=/`;
});
// 3. Перезагрузка страницы для сброса состояния интерфейса
window.location.reload();
},
init() {
// Привязка клика ко всем элементам с классом или id logout
document.addEventListener("DOMContentLoaded", () => {
const logoutBtns = document.querySelectorAll("#logout-btn, .logout-btn, [data-action='logout']");
logoutBtns.forEach(btn => {
btn.addEventListener("click", (e) => {
e.preventDefault();
AuthManager.logout();
});
});
});
} }
}; }
// Инициализация при загрузке скрипта function enableGuestMode() {
AuthManager.init(); IS_GUEST = true;
API_TOKEN = "";
CURRENT_USERNAME = "Гость";
IS_ADMIN = false;
localStorage.setItem("scud_is_guest", "true");
hideAuthModal();
updateUIState();
}
// Глобальная доступность функции для onclick в HTML function logout() {
window.logout = () => AuthManager.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("Ошибка при удалении");
}
}
+612 -58
View File
@@ -1,67 +1,621 @@
/** /**
* =============================================================================== ===============================================================================
* FILE: static/js/chat/core.js FILE: modules/web_api/static/js/chat/core.js
* ROLE: Отправка сообщений в API с токеном и обработка ошибок. PROJECT: SCUD Orion AI (Unified Architecture)
* =============================================================================== MODULE: web_api / static / js / chat
*/ ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода
при активных кнопках, вызов инлайн-редактора, Diff и карточки скачивания файлов.
===============================================================================
*/
async function sendMessage(userMessageText) { function updateInputHeight(el) {
const chatInput = document.getElementById("chat-input"); if (!el) return;
const message = userMessageText || (chatInput ? chatInput.value.trim() : ""); el.style.height = "24px";
if (!message) return; const newHeight = Math.min(el.scrollHeight, 120);
el.style.height = newHeight + "px";
}
if (chatInput && !userMessageText) { let selectedFile = null;
chatInput.value = "";
function handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
if (file.size > 15 * 1024 * 1024) {
alert("Файл слишком большой. Максимальный размер: 15 МБ");
e.target.value = "";
return;
} }
// Отображаем сообщение пользователя в чате selectedFile = file;
if (typeof appendMessageToUI === "function") { const fileNameEl = document.getElementById("file-name-display");
appendMessageToUI("user", message); const fileSizeEl = document.getElementById("file-size-display");
const previewContainer = document.getElementById("file-preview-container");
if (fileNameEl) fileNameEl.innerText = file.name;
if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`;
if (previewContainer) previewContainer.classList.remove("hidden");
}
function clearAttachedFile() {
selectedFile = null;
const fileInput = document.getElementById("file-input");
const previewContainer = document.getElementById("file-preview-container");
if (fileInput) fileInput.value = "";
if (previewContainer) previewContainer.classList.add("hidden");
}
function setInputLocked(isLocked) {
const input = document.getElementById("user-input");
const sendBtn = document.getElementById("send-btn");
if (input) {
input.disabled = isLocked;
if (isLocked) {
input.placeholder = "Выберите действие с помощью кнопок выше...";
input.classList.add("cursor-not-allowed", "opacity-60", "bg-slate-200/50");
} else {
input.placeholder = "Команда, вопрос или перетащите файл сюда...";
input.classList.remove("cursor-not-allowed", "opacity-60", "bg-slate-200/50");
}
} }
if (sendBtn) {
// Подготовка заголовков с гарантированным токеном sendBtn.disabled = isLocked;
const token = (typeof AuthManager !== "undefined") ? AuthManager.getToken() : (localStorage.getItem("auth_token") || "dev_token_1"); if (isLocked) {
const userId = (typeof AuthManager !== "undefined") ? AuthManager.getUserId() : parseInt(localStorage.getItem("user_id") || "1", 10); sendBtn.classList.add("opacity-40", "cursor-not-allowed");
const sessionId = localStorage.getItem("chat_session_id") || "web_session_main"; } else {
sendBtn.classList.remove("opacity-40", "cursor-not-allowed");
try {
const response = await fetch("/api/v1/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
message: message,
session_id: sessionId,
user_id: userId
})
});
if (response.status === 403) {
console.warn("[Chat] Получен 403 Forbidden. Сбрасываем сессию и повторяем...");
localStorage.removeItem("auth_token");
if (typeof appendMessageToUI === "function") {
appendMessageToUI("assistant", "⚠️ Сессия была обновлена. Пожалуйста, отправьте сообщение повторно.");
}
return;
}
if (!response.ok) {
throw new Error(`Ошибка сервера (HTTP ${response.status})`);
}
const data = await response.json();
const reply = data.response || data.message || "Ответ получен без текста.";
if (typeof appendMessageToUI === "function") {
appendMessageToUI("assistant", reply);
}
} catch (err) {
console.error("[Chat Error]:", err);
if (typeof appendMessageToUI === "function") {
appendMessageToUI("assistant", `⚠️ Не удалось связаться с сервером: ${err.message}`);
} }
} }
} }
function disableAllActionButtons() {
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
allBtnContainers.forEach(container => {
container.querySelectorAll("button").forEach(btn => {
btn.disabled = true;
btn.classList.add("opacity-40", "cursor-not-allowed");
});
});
}
function handleActionButtonClick(text) {
disableAllActionButtons();
setInputLocked(false);
const input = document.getElementById("user-input");
if (input) {
input.value = text;
sendMessage();
}
}
function escapeHtml(text) {
if (text === null || text === undefined) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] ---
function openInlinePromptEditor(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
const diffView = cardContainer.querySelector(".prompt-preview-diff-view");
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
if (editorContainer && textarea) {
editorContainer.classList.remove("hidden");
if (diffView) {
const targetHeight = Math.max(diffView.offsetHeight, 320);
textarea.style.height = `${targetHeight}px`;
}
textarea.focus();
}
}
function closeInlinePromptEditor(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
if (editorContainer) editorContainer.classList.add("hidden");
}
function parsePromptIntoMap(text) {
const map = new Map();
if (!text) return map;
const lines = text.split("\n");
for (let line of lines) {
let trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
if (match) {
const key = `${match[1]}.${match[2]}`;
map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed });
}
}
return map;
}
function buildHighlightedPromptHtml(newRawText, originalBaselineText) {
const origMap = parsePromptIntoMap(originalBaselineText);
const newMap = parsePromptIntoMap(newRawText);
const formattedLines = [];
const rawLines = newRawText.split("\n");
const handledNewKeys = new Set();
let currentSection = null;
for (let line of rawLines) {
let trimmed = line.trim();
if (!trimmed) {
formattedLines.push("");
continue;
}
const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/);
const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
if (subMatch) {
const key = `${subMatch[1]}.${subMatch[2]}`;
handledNewKeys.add(key);
currentSection = parseInt(subMatch[1]);
const cleanContent = subMatch[3].trim();
const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`;
if (!origMap.has(key) || origMap.get(key).content !== cleanContent) {
formattedLines.push(` <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">${escapeHtml(fullItemStr)}</span>`);
} else {
formattedLines.push(` ${escapeHtml(fullItemStr)}`);
}
} else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) {
currentSection = parseInt(secMatch[1]);
formattedLines.push(escapeHtml(trimmed));
} else {
formattedLines.push(escapeHtml(trimmed));
}
}
for (let [origKey, origObj] of origMap.entries()) {
if (!handledNewKeys.has(origKey)) {
const strikeMarkup = ` <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">${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]</span>`;
formattedLines.push(strikeMarkup);
}
}
return formattedLines.join("\n");
}
function anyLower(str) {
return /[а-яa-z]/.test(str);
}
async function saveManualPromptDraft(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view");
if (!textarea) return;
const newText = textarea.value.trim();
if (!newText) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
try {
const res = await fetch("/api/v1/chat/draft", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({
session_id: "web_session_main",
draft_text: newText
})
});
if (res.ok) {
closeInlinePromptEditor(btnEl);
if (previewTextEl) {
const baseline = cardContainer.dataset.originalBaseline || "";
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
previewTextEl.innerHTML = highlightedHtml;
}
let actionButtonsContainer = cardContainer.querySelector(".action-buttons-container");
if (actionButtonsContainer) {
actionButtonsContainer.innerHTML = `
<button type="button" onclick="handleActionButtonClick('подтверждаю')"
class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-check"></i>
<span>Подтвердить</span>
</button>
<button type="button" onclick="handleActionButtonClick('отмена')"
class="bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-xmark"></i>
<span>Отменить</span>
</button>
<button type="button" onclick="openInlinePromptEditor(this)"
class="bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-pen-to-square text-xs"></i>
<span>✏️ Редактировать</span>
</button>
`;
}
}
} catch (err) {
alert("Ошибка сохранения черновика: " + err);
}
}
// ⭐️ Абсолютный расчет позиции скролла к верху видимой области
function scrollToTurnTop(anchorId) {
const chatWindow = document.getElementById("chat-window");
const anchorEl = document.getElementById(anchorId);
if (!chatWindow || !anchorEl) return;
const windowRect = chatWindow.getBoundingClientRect();
const anchorRect = anchorEl.getBoundingClientRect();
const currentScroll = chatWindow.scrollTop;
// Новая позиция скролла = текущий сдвиг + расстояние от верхней кромки окна до элемента
const targetTop = currentScroll + (anchorRect.top - windowRect.top) - 12;
chatWindow.scrollTo({
top: Math.max(0, targetTop),
behavior: "smooth"
});
}
// --- [CHAT SEND PIPELINE (GEMINI STYLE TOP-SCROLL)] ---
async function sendMessage(e) {
if (e && e.preventDefault) e.preventDefault();
const input = document.getElementById("user-input");
const chatWindow = document.getElementById("chat-window");
const sendBtn = document.getElementById("send-btn");
if (!input || !chatWindow) return;
const text = input.value.trim();
if (!text && !selectedFile) return;
disableAllActionButtons();
let userDisplayHtml = escapeHtml(text);
if (selectedFile) {
userDisplayHtml = `<div class="font-bold border-b border-indigo-400/40 pb-1 mb-1 text-[11px] flex items-center gap-1.5">
<i class="fa-solid fa-file"></i> ${escapeHtml(selectedFile.name)}
</div>` + userDisplayHtml;
}
const turnId = "turn-" + Date.now();
const userMsgHtml = `
<div id="${turnId}" class="chat-turn-anchor flex justify-end mb-3 pt-2 scroll-mt-3">
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-xl text-xs sm:text-sm shadow-sm leading-relaxed">
${userDisplayHtml}
</div>
</div>
`;
// Вставляем вопрос строго перед невидимой распоркой
const spacer = document.getElementById("chat-bottom-spacer");
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", userMsgHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
}
input.value = "";
updateInputHeight(input);
// Скроллим сразу при отправке вопроса
setTimeout(() => scrollToTurnTop(turnId), 50);
if (sendBtn) {
sendBtn.disabled = true;
sendBtn.classList.add("opacity-50");
}
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
const formData = new FormData();
formData.append("session_id", "web_session_main");
formData.append("message", text || "Проанализируй прикрепленный файл");
if (selectedFile instanceof File) {
formData.append("file", selectedFile, selectedFile.name);
}
const headers = {};
if (!isGuest && token) {
headers["Authorization"] = "Bearer " + token;
}
try {
const res = await fetch(endpoint, {
method: "POST",
headers: headers,
body: formData
});
if (res.status === 401 && !isGuest) {
if (typeof logout === 'function') logout();
return;
}
const data = await res.json();
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
const replyText = data.reply || "Пустой ответ от нейросети";
let actionButtonsHtml = "";
const actionData = data.action_type || data.action;
if (actionData && actionData.buttons && actionData.buttons.length > 0) {
const buttonsMarkup = actionData.buttons.map(btn => {
let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300";
let iconMarkup = '<i class="fa-solid fa-arrow-right text-[10px] opacity-60"></i>';
const labelLower = (btn.label || "").toLowerCase();
const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да");
const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш");
const isEdit = labelLower.includes("редактир");
if (isPrimary) {
btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm";
iconMarkup = '<i class="fa-solid fa-check"></i>';
} else if (isDanger) {
btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300";
iconMarkup = '<i class="fa-solid fa-xmark"></i>';
} else if (isEdit) {
btnClasses = "bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300";
iconMarkup = '<i class="fa-solid fa-pen-to-square text-xs"></i>';
}
const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`;
return `
<button type="button" onclick="${clickHandler}"
class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
${iconMarkup}
<span>${escapeHtml(btn.label)}</span>
</button>
`;
}).join("");
actionButtonsHtml = `
<div class="action-buttons-container flex flex-wrap items-center gap-2 mt-3 pt-2.5 border-t border-slate-100">
${buttonsMarkup}
</div>
`;
setInputLocked(true);
} else {
setInputLocked(false);
}
let previewEditorHtml = "";
let rawDraftText = "";
let baselineText = "";
if (actionData && actionData.type === "PROMPT_PREVIEW") {
rawDraftText = actionData.raw_draft || "";
baselineText = actionData.baseline_prompt || rawDraftText;
previewEditorHtml = `
<div class="inline-prompt-editor-container hidden mt-3 p-3 bg-slate-50 border border-slate-300 rounded-xl space-y-2">
<p class="text-[11px] font-bold text-slate-700 uppercase"><i class="fa-solid fa-pen-to-square mr-1"></i> Ручное редактирование текста промпта:</p>
<textarea class="inline-prompt-textarea w-full bg-white border border-slate-300 rounded-lg p-3 text-xs font-mono text-slate-800 focus:outline-none focus:border-indigo-600 resize-y shadow-inner leading-relaxed">${escapeHtml(rawDraftText)}</textarea>
<div class="flex justify-end gap-2 pt-1">
<button type="button" onclick="closeInlinePromptEditor(this)" class="px-3 py-1.5 rounded-lg text-xs text-slate-600 bg-slate-200 hover:bg-slate-300 transition">Свернуть</button>
<button type="button" onclick="saveManualPromptDraft(this)" class="px-3.5 py-1.5 rounded-lg text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-sm transition">Сохранить правки</button>
</div>
</div>
`;
}
let interactiveWidgetHtml = "";
if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && typeof renderInteractiveTaskCard === "function") {
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks);
}
let snapshotsWidgetHtml = "";
if (actionData && actionData.type === "SNAPSHOTS_CARD" && typeof renderSnapshotsCard === "function") {
snapshotsWidgetHtml = renderSnapshotsCard(actionData.data);
}
let fileDownloadHtml = "";
if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") {
fileDownloadHtml = `
<div class="mt-3 p-3 bg-emerald-50 border border-emerald-200 rounded-xl flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<i class="fa-solid fa-file-lines text-emerald-600 text-lg shrink-0"></i>
<div class="truncate">
<div class="text-xs font-bold text-slate-900 truncate">${escapeHtml(actionData.filename)}</div>
<div class="text-[11px] text-emerald-700">Готов к скачиванию (задач: ${actionData.tasks_count || '—'})</div>
</div>
</div>
<a href="${actionData.download_url}" download="${escapeHtml(actionData.filename)}"
class="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-semibold rounded-lg text-xs flex items-center gap-1.5 transition shrink-0 shadow-sm">
<i class="fa-solid fa-download"></i>
<span>Скачать</span>
</a>
</div>
`;
}
const isWideWidget = actionData && (actionData.type === "TASK_INTERACTIVE_CARD" || actionData.type === "PROMPT_PREVIEW");
const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl";
const botMsgHtml = `
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full mb-3" data-original-baseline="${escapeHtml(baselineText)}">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
</p>
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
${previewEditorHtml}
${interactiveWidgetHtml}
${snapshotsWidgetHtml}
${fileDownloadHtml}
${actionButtonsHtml}
</div>
`;
// Вставляем карточку ответа строго перед невидимой распоркой
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", botMsgHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
}
// Скроллим к началу хода после рендера карточки ответа
setTimeout(() => scrollToTurnTop(turnId), 100);
clearAttachedFile();
if (!isGuest && typeof loadTasks === 'function') {
loadTasks();
}
} catch (err) {
console.error("[Chat Error]", err);
const errorHtml = `
<div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm mb-3">
Ошибка связи с сервером.
</div>
`;
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", errorHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
}
setTimeout(() => scrollToTurnTop(turnId), 50);
setInputLocked(false);
} finally {
const inputEl = document.getElementById("user-input");
if (sendBtn && (!inputEl || !inputEl.disabled)) {
sendBtn.disabled = false;
sendBtn.classList.remove("opacity-50");
}
}
}
// --- [DRAG & DROP AND KEYBOARD LISTENERS] ---
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input");
const dropZone = document.getElementById("chat-window")?.parentElement;
const dropOverlay = document.getElementById("drop-overlay");
if (input) {
let historyIndex = -1;
let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (input.disabled) return;
const text = input.value.trim();
if (text) {
if (localHistory.length === 0 || localHistory[0] !== text) {
localHistory.unshift(text);
if (localHistory.length > 50) localHistory.pop();
localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
}
historyIndex = -1;
}
sendMessage(e);
updateInputHeight(input);
return;
}
if (e.key === "ArrowUp") {
const textBeforeCursor = input.value.substring(0, input.selectionStart);
const isFirstLine = !textBeforeCursor.includes("\n");
if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
if (historyIndex < localHistory.length - 1) {
e.preventDefault();
if (historyIndex === -1) {
input.dataset.draft = input.value;
}
historyIndex++;
input.value = localHistory[historyIndex];
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
if (e.key === "ArrowDown") {
const textAfterCursor = input.value.substring(input.selectionEnd);
const isLastLine = !textAfterCursor.includes("\n");
if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
input.value = localHistory[historyIndex];
} else {
historyIndex = -1;
input.value = input.dataset.draft || "";
}
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
});
}
if (dropZone && dropOverlay) {
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
["dragenter", "dragover"].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropOverlay.classList.remove("hidden");
dropOverlay.classList.add("flex");
}, false);
});
["dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
dropOverlay.classList.add("hidden");
dropOverlay.classList.remove("flex");
}
}, false);
});
dropZone.addEventListener("drop", (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length > 0) {
const file = files[0];
handleFileSelect({ target: { files: [file] } });
const fileInput = document.getElementById("file-input");
if (fileInput) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
}
}
}, false);
}
});
-20
View File
@@ -1,20 +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
# Запуск main_etl.py через интерпретатор окружения
/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
+51 -137
View File
@@ -7,7 +7,7 @@ from datetime import datetime
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
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 ( from core.database import (
get_connection, get_connection,
get_available_snapshots, get_available_snapshots,
@@ -15,16 +15,9 @@ from core.database import (
load_scud_from_db_by_snapshot, load_scud_from_db_by_snapshot,
get_latest_snapshot_time 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") DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def print_tool_actions(): def print_tool_actions():
"""Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" """Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
print("\n" + "=" * 110) print("\n" + "=" * 110)
@@ -32,29 +25,25 @@ def print_tool_actions():
print("=" * 110) print("=" * 110)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
try: cursor.execute("""
cursor.execute(""" SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json
SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json FROM tool_action_registry
FROM tool_action_registry WHERE is_active = 1
WHERE is_active = 1 ORDER BY id ASC
ORDER BY id ASC """)
""") rows = cursor.fetchall()
rows = cursor.fetchall() if not rows:
if not rows: print("Таблица tool_action_registry пуста.")
print("Таблица tool_action_registry пуста.") else:
else: for r in rows:
for r in rows: print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}")
print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}") print(f" • Сообщение: {r[4]}")
print(f" • Сообщение: {r[4]}") if r[5]:
if r[5]: print(f" • Вопрос: {r[5]}")
print(f" • Вопрос: {r[5]}") print(f" • Кнопки: {r[6]}")
print(f" • Кнопки: {r[6]}") print("-" * 110)
print("-" * 110)
except Exception as e:
print(f"Таблица tool_action_registry недоступна: {e}")
print("=" * 110 + "\n") print("=" * 110 + "\n")
def print_stats(): def print_stats():
"""Выводит общую статистику по записям в таблицах БД.""" """Выводит общую статистику по записям в таблицах БД."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
@@ -62,16 +51,12 @@ def print_stats():
print("=" * 60) print("=" * 60)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
tables = [ tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']
'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history',
'ai_knowledge_base', 'chat_messages', 'session_states',
'system_prompt_nodes', 'tasks', 'exceptions_registry'
]
for t in tables: for t in tables:
try: try:
cursor.execute(f"SELECT COUNT(*) FROM {t}") cursor.execute(f"SELECT COUNT(*) FROM {t}")
cnt = cursor.fetchone()[0] cnt = cursor.fetchone()[0]
print(f" • Таблица [{t:<22}]: {cnt:>6} записей") print(f" • Таблица [{t:<20}]: {cnt:>6} записей")
except Exception: except Exception:
pass pass
print("=" * 60 + "\n") print("=" * 60 + "\n")
@@ -97,11 +82,13 @@ def print_snapshots_list(date_str=None):
def snapshot_sort_key(row): def snapshot_sort_key(row):
snap_id = row[0] or "" snap_id = row[0] or ""
snap_time = row[2] or "" snap_time = row[2] or ""
seq_num = 0 seq_num = 0
if "-" in snap_id: if "-" in snap_id:
parts = snap_id.replace("Y", "").split("-") parts = snap_id.replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit(): if len(parts) > 1 and parts[1].isdigit():
seq_num = int(parts[1]) seq_num = int(parts[1])
return (snap_time, seq_num) return (snap_time, seq_num)
sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True) sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True)
@@ -117,7 +104,11 @@ def print_snapshots_list(date_str=None):
time_part = snap_time.split(" ")[1] time_part = snap_time.split(" ")[1]
slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date 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}") print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}")
@@ -150,44 +141,31 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
"""Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX.""" """Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX."""
target_date = date_str if date_str else DATE_TODAY target_date = date_str if date_str else DATE_TODAY
print("\n" + "=" * 115) print("\n" + "=" * 90)
if snapshot_id: if snapshot_id:
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата среза: {target_date}):") print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):")
else: else:
print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):") print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):")
print("=" * 115) print("=" * 90)
df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id) 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("Записи СКУД не найдены.")
print("=" * 115 + "\n") print("=" * 90 + "\n")
return 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) total = len(df)
if present_col: present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0
present_cnt = len(df[df[present_col].astype(str).str.lower().isin(['true', '1'])])
else:
present_cnt = 0
absent_cnt = total - present_cnt absent_cnt = total - present_cnt
print(f"Всего записей: {total} | Присутствовали: {present_cnt} | Отсутствовали: {absent_cnt}") print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}")
print("-" * 115) 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] cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id']
print(df[display_cols].head(30).to_string(index=False)) 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: if len(df) > 30:
print(f"\n... и ещё {len(df) - 30} строк.") print(f"\n... и ещё {len(df) - 30} строк.")
@@ -197,11 +175,11 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
out_path = os.path.join(OUTPUT_DIR, out_path) out_path = os.path.join(OUTPUT_DIR, out_path)
df.to_excel(out_path, index=False) df.to_excel(out_path, index=False)
print("\n" + "*" * 115) print("\n" + "*" * 90)
print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}") print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}")
print("*" * 115) print("*" * 90)
print("=" * 115 + "\n") print("=" * 90 + "\n")
def print_absences(date_str=None): def print_absences(date_str=None):
@@ -261,7 +239,7 @@ def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
out_path = os.path.join(OUTPUT_DIR, out_filename) out_path = os.path.join(OUTPUT_DIR, out_filename)
print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks', 'exceptions_registry']: for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']:
try: try:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn) df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False) df.to_excel(writer, sheet_name=table[:31], index=False)
@@ -355,11 +333,6 @@ def print_chat_messages(session_id=None, limit=50):
def purge_chat_context(session_id=None, purge_all=False): def purge_chat_context(session_id=None, purge_all=False):
"""
Очистка контекста сообщений:
- По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий.
- purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии.
"""
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -379,6 +352,8 @@ def purge_chat_context(session_id=None, purge_all=False):
DELETE FROM chat_messages DELETE FROM chat_messages
WHERE is_ephemeral = 1 WHERE is_ephemeral = 1
OR content LIKE '%Предпросмотр изменений%' OR content LIKE '%Предпросмотр изменений%'
OR content LIKE '%Актуальный системный промпт:%'
OR content LIKE '%1. РОЛЬ И ЗАДАЧИ АССИСТЕНТА%'
OR content LIKE '%Удален пункт:%' OR content LIKE '%Удален пункт:%'
OR content LIKE '%добавлен пункт:%' OR content LIKE '%добавлен пункт:%'
""" """
@@ -395,24 +370,6 @@ def purge_chat_context(session_id=None, purge_all=False):
print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n") print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
# ⭐️ Новые функции управления исключениями (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}")
else:
print(" — пусто")
print("-" * 80)
print("=" * 80 + "\n")
HELP_TEXT = """ HELP_TEXT = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
@@ -431,11 +388,6 @@ CLI-утилита инспекции и управления SQLite базой
dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день 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 stats
python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py snapshots 06.08.2026
@@ -450,11 +402,6 @@ CLI-утилита инспекции и управления SQLite базой
python scripts/db_cli.py context purge web_session_main --all python scripts/db_cli.py context purge web_session_main --all
python scripts/db_cli.py snapshot del Y20260805-007 python scripts/db_cli.py snapshot del Y20260805-007
python scripts/db_cli.py dump my_dump.xlsx 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
""" """
@@ -468,16 +415,9 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False add_help=False
) )
parser.add_argument('command', nargs='?', default=None, choices=[ parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools', 'context'], help="Основная команда")
'stats', 'snapshots', 'scud', 'absences', 'anomalies', parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del', 'purge') или session_id для контекста")
'rules', 'prompts', 'sessions', 'dump', 'snapshot', parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, '--all' или имя файла)")
'tools', 'context', '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'], help="Категория исключения")
parser.add_argument('-v', '--value', type=str, default=None, help="Значение исключения (ФИО, отдел, должность)")
parser.add_argument('-m', '--comment', type=str, default="", help="Комментарий к исключению")
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
@@ -493,13 +433,12 @@ def main():
if args.command == 'stats': if args.command == 'stats':
print_stats() print_stats()
elif args.command == 'snapshots': 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) print_snapshots_list(date_str=date_val)
elif args.command == 'scud': elif args.command == 'scud':
date_val = args.action or args.param inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx)
inspect_scud(snapshot_id=args.snapshot, date_str=date_val, export_xlsx=args.export_xlsx)
elif args.command == 'absences': elif args.command == 'absences':
date_val = args.action or args.param date_val = args.param or args.action
print_absences(date_str=date_val) print_absences(date_str=date_val)
elif args.command == 'anomalies': elif args.command == 'anomalies':
print_anomalies() print_anomalies()
@@ -520,7 +459,7 @@ def main():
sess_id = args.action if args.action else None sess_id = args.action if args.action else None
print_chat_messages(session_id=sess_id, limit=args.limit) print_chat_messages(session_id=sess_id, limit=args.limit)
elif args.command == 'dump': 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) dump_all_to_excel(filename)
elif args.command == 'snapshot': elif args.command == 'snapshot':
if args.action == 'del': if args.action == 'del':
@@ -534,34 +473,9 @@ def main():
else: else:
print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.") print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") 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: else:
print("\n[❌] Ошибка: Неизвестная команда.") print("\n[❌] Ошибка: Неизвестная команда.")
print(HELP_TEXT) print(HELP_TEXT)
if __name__ == "__main__": if __name__ == "__main__":
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)")
-73
View File
@@ -1,73 +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")
# Строгий целевой список файлов для ETL-слепка
TARGET_FILES = [
"config.py",
"exceptions.json",
"main_etl.py",
"run_cron_etl.sh",
"scripts/db_cli.py", # ⭐️ Гарантированно включен
"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/text_reporter.py",
"services/exceptions_repo.py",
"services/zup_extractor.py",
"services/ai_verifier.py",
"services/knowledge_base.py",
"services/knowledge/service.py",
"services/scud_etl/pipeline.py",
"services/scud_etl/merger.py",
"services/scud_etl/anomaly_detector.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 = "py" if ext == "py" else ("json" if ext == "json" else "bash")
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()
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""
===============================================================================
FILE: scripts/diagnostics/make_web_snapshot.py
ROLE: Генерация слепка Web API, 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/auth.py",
"modules/web_api/routers/tasks.py",
"modules/web_api/routers/exceptions.py",
"modules/web_api/routers/admin.py",
"modules/web_api/routers/files.py",
"modules/web_api/llm/agent.py",
"modules/web_api/llm/db_tools.py",
"modules/web_api/llm/schemas.py",
"modules/web_api/llm/core/context_manager.py",
"modules/web_api/llm/core/tool_injector.py",
"modules/web_api/llm/core/ollama_client.py",
"modules/web_api/llm/core/fast_path.py",
"modules/web_api/static/js/app.js",
"modules/web_api/static/js/auth.js",
"modules/web_api/static/js/tasks.js",
"modules/web_api/static/js/chat/core.js",
"modules/web_api/static/js/chat/task_widget.js"
]
def create_web_snapshot():
content = ["# 🌐 WEB API & LLM AGENT 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 = "js" if ext == "js" else ("py" if ext == "py" else "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(content))
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
print(f"\n[✓] Web API слепок создан: {OUTPUT_FILE}")
print(f" Включено файлов: {included_count} | Размер: {size_kb:.1f} KB\n")
if __name__ == "__main__":
create_web_snapshot()
@@ -1,22 +0,0 @@
import os
OUTPUT_SNAPSHOT = "web_api_code_snapshot.md"
ALLOWED_EXTENSIONS = {'.py', '.html', '.css', '.js'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project', 'docs', 'data'}
print(f"🔄 Сборка Web API слепка кода в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 WEB API & AI СЛЕПОК ИСХОДНОГО КОДА\n\n")
if os.path.exists('modules'):
for r, d, files in os.walk('modules'):
d[:] = [sub for sub in d if sub not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS:
filepath = os.path.join(r, file)
out.write(f"## File: `./{filepath}`\n```" + ext.replace('.', '') + "\n")
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
out.write("\n```\n\n")
print(f"✓ Web API слепок готов: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT) / 1024:.1f} KB)")
+155 -54
View File
@@ -1,17 +1,11 @@
"""
===============================================================================
FILE: services/data_loader.py
ROLE: Надежная загрузка штата и отсутствий (MS SQL ЗУП -> Резервный Excel).
===============================================================================
"""
import os import os
import warnings
import pandas as pd import pandas as pd
import warnings
from config import ( from config import (
normalize_fio, clean_scud_fio_light, load_exceptions, 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 services.zup_extractor import fetch_zup_absences_from_sql
from core.database import ( from core.database import (
load_scud_from_db_by_snapshot, load_scud_from_db_by_snapshot,
@@ -22,8 +16,23 @@ from core.database import (
warnings.filterwarnings('ignore', category=UserWarning, module='pandas') 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): def load_staff_data(date_str):
"""Загружает файл Штатного расписания 1С.""" """
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
"""
filepath = find_dated_file("Штат", date_str) filepath = find_dated_file("Штат", date_str)
if not filepath: if not filepath:
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx") fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
@@ -34,6 +43,8 @@ def load_staff_data(date_str):
try: try:
df_raw = pd.read_excel(filepath, skiprows=8) df_raw = pd.read_excel(filepath, skiprows=8)
# Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность
df_staff = df_raw.iloc[:, [1, 5, 12]].copy() df_staff = df_raw.iloc[:, [1, 5, 12]].copy()
df_staff.columns = ['ФИО', 'Подразделение', 'Должность'] df_staff.columns = ['ФИО', 'Подразделение', 'Должность']
@@ -46,63 +57,30 @@ def load_staff_data(date_str):
return None 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): def load_absent_data(date_str):
""" """
Загружает отсутствия из MS SQL 1С:ЗУП, а при сбое — из локального Excel. Загружает документально подтвержденные отсутствия сотрудников
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату,
а также автоматически обогащает их удаленщиками из static_reason_workers.csv.
""" """
df_absent = None df_absent = None
try: try:
df_absent = fetch_zup_absences_from_sql(date_str) df_absent = fetch_zup_absences_from_sql(date_str)
if df_absent is not None and not df_absent.empty: 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.dropna(subset=['ФИО', 'Вид_отсутствия'])
df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)] 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['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean']) df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
else:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
except Exception as e: except Exception as e:
print(f" [⚠️] Ошибка подключения к MS SQL ЗУП за {date_str}: {e}") print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
# Резервный источник: Excel файл из 1С с сетевой шары
if df_absent is None or df_absent.empty:
df_absent = load_absent_from_excel(date_str)
if df_absent is None:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
# Обогащение удаленщиками из static_reason_workers.csv # ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv
try: try:
from config import DATA_DIR
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if os.path.exists(static_path): if os.path.exists(static_path):
df_static = pd.read_csv(static_path, encoding='utf-8') df_static = pd.read_csv(static_path, encoding='utf-8')
@@ -119,14 +97,18 @@ def load_absent_data(date_str):
}) })
if new_rows: if new_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True) df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows)} чел. из static_reason_workers.csv")
except Exception as e: except Exception as e:
print(f" [⚠️] Ошибка чтения static_reason_workers.csv: {e}") print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}")
return df_absent if not df_absent.empty else None return df_absent if not df_absent.empty else None
def load_1c_data_smart(date_str, use_db=False): def load_1c_data_smart(date_str, use_db=False):
"""
Универсальный загрузчик данных 1С (Штат и Отсутствия).
Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД
за целевую дату указанного снапшота.
"""
df_staff = None df_staff = None
df_absent = None df_absent = None
@@ -134,9 +116,128 @@ def load_1c_data_smart(date_str, use_db=False):
df_staff = load_staff_from_db(date_str) df_staff = load_staff_from_db(date_str)
df_absent = load_absences_from_db(date_str) df_absent = load_absences_from_db(date_str)
# Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL
if df_staff is None: if df_staff is None:
df_staff = load_staff_data(date_str) df_staff = load_staff_data(date_str)
if df_absent is None: if df_absent is None:
df_absent = load_absent_data(date_str) df_absent = load_absent_data(date_str)
return df_staff, df_absent return df_staff, df_absent
def process_scud_anomalies(df_scud, exceptions=None):
"""
Вычисляет 'Первую активность' и маркирует аномалии СКУД.
"""
if exceptions is None:
exceptions = load_exceptions()
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None)
status_col = 'Статус' if 'Статус' in df_scud.columns else None
first_activities = []
anomaly_flags = []
is_present_flags = []
for _, row in df_scud.iterrows():
fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip()
fio_clean = normalize_fio(fio)
dept = str(row.get('Подразделение', '')).strip()
pos = str(row.get('Должность', '')).strip()
val_start = str(row.get(start_col, '')).strip() if start_col else ''
val_end = str(row.get(end_col, '')).strip() if end_col else ''
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
raw_first_event = row.get('raw_first_event', None)
if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']:
raw_first_event = None
has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00'])
has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00'])
first_act = '—'
anom_flag = 'NONE'
is_present = not ('отсутствовал' in val_status or has_no_in)
if has_no_in:
if raw_first_event is not None or not has_no_out:
first_act = str(raw_first_event) if raw_first_event else val_end
anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY'
if not is_excluded(fio, dept, pos, exceptions):
is_present = False
else:
first_act = '—'
is_present = False
first_activities.append(first_act)
anomaly_flags.append(anom_flag)
is_present_flags.append(is_present)
df_scud['Первая_активность'] = first_activities
df_scud['anomaly_flag'] = anomaly_flags
df_scud['Пришел'] = is_present_flags
return df_scud
def load_scud_data(target_date_type="today", snapshot_param=None):
"""
Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite.
"""
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.")
return pd.DataFrame()
exceptions = load_exceptions()
if 'fio_clean' not in df_scud.columns:
fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0])
df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str)
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns:
df_scud['Начало_дня'] = df_scud['time_in']
if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns:
df_scud['Конец_дня'] = df_scud['time_out']
if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns:
df_scud['Первая_активность'] = df_scud['first_activity']
if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns:
df_scud['Пришел'] = df_scud['is_present'].astype(bool)
df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions),
axis=1
)
return df_scud
def load_all_data():
"""
Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С.
"""
df_scud_today = load_scud_data(target_date_type="today")
try:
df_scud_yesterday = load_scud_data(target_date_type="yesterday")
except FileNotFoundError:
df_scud_yesterday = df_scud_today.copy()
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY)
return (
df_scud_today,
df_scud_yesterday,
df_staff_today,
df_absent_today,
df_staff_yesterday,
df_absent_yesterday
)
+41 -98
View File
@@ -7,65 +7,48 @@ from datetime import datetime
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from config import REPORTS_DIR from config import OUTPUT_DIR
MONTHS_RU_GENITIVE = { # Словарь месяцев для текстового формата в названиях файлов
MONTHS_RU = {
1: "января", 2: "февраля", 3: "марта", 4: "апреля", 1: "января", 2: "февраля", 3: "марта", 4: "апреля",
5: "мая", 6: "июня", 7: "июля", 8: "августа", 5: "мая", 6: "июня", 7: "июля", 8: "августа",
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" 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): def format_date_ru(date_str):
"""Преобразует дату формата '27.07.2026' в '27 июля 2026'"""
try: try:
dt = datetime.strptime(date_str, "%d.%m.%Y") dt = datetime.strptime(date_str, "%d.%m.%Y")
return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}" return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}"
except Exception: except Exception:
return date_str return date_str
def get_dated_reports_dir(date_str): # Границы ячеек
try:
dt = datetime.strptime(date_str, "%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
THIN_SIDE = Side(border_style="thin", color="D3D3D3") THIN_SIDE = Side(border_style="thin", color="D3D3D3")
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE) 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_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_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_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid")
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", 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") FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска"
FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") # Мягкий бежево-серый для исключений
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"] 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") YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", 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): 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() != "": if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
return "0:00" return "0:00"
@@ -118,12 +101,11 @@ def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_f
# --- 1. СВОДКА НА СЕГОДНЯ --- # --- 1. СВОДКА НА СЕГОДНЯ ---
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
if not filename: if not filename:
filename = f"{format_date_ru(date_str)} сводка.xlsx" filename = f"{format_date_ru(date_str)} сводка.xlsx"
target_dir = get_dated_reports_dir(date_str) output_path = os.path.join(OUTPUT_DIR, filename)
output_path = os.path.join(target_dir, filename)
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "Лист_1" ws.title = "Лист_1"
@@ -150,10 +132,11 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
current_row = 4 current_row = 4
# Подготовка флагов для точной фильтрации
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False 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 is_exc = merged_df.get('is_excluded', False) == True
# 3. НЕИЗВЕСТНО # 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана")
unexplained = merged_df[ unexplained = merged_df[
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
@@ -172,7 +155,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = False ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА # 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок)
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() 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=1, value="Нет пропуска")
@@ -188,16 +171,11 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = False ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (без удаленки и исключений) # 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
absent_only = merged_df[ absent_only = merged_df[
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
(~is_remote_reason) &
(~is_exc)
] ]
absent_groups = absent_only.groupby('Вид_отсутствия') absent_groups = absent_only.groupby('Вид_отсутствия')
@@ -217,8 +195,11 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 6. ИТОГО НА РАБОТЕ (Строго физически пришедшие в офис по СКУД) # 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] 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=1, value="Итого на работе")
ws.cell(row=current_row, column=2, value=len(present)) ws.cell(row=current_row, column=2, value=len(present))
@@ -233,29 +214,10 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 6.1. В ТОМ ЧИСЛЕ НА УДАЛЕННОЙ РАБОТЕ (Удаленщики из дома, которых нет в офисе) # 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
ws.cell(row=current_row, column=1, value="В том числе на удаленной работе")
ws.cell(row=current_row, column=2, value=len(remote_home))
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font)
current_row += 1
if not remote_home.empty:
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_REMOTE, 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[ anomalies = merged_df[
(~is_exc) & ( (~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) |
(~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') (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
) )
] ]
@@ -300,22 +262,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# ⭐️ 8. РАЗДЕЛ: ИСКЛЮЧЕНИЯ (В самом низу, раскрывающийся список)
exceptions_df = merged_df[is_exc]
ws.cell(row=current_row, column=1, value="Исключения")
ws.cell(row=current_row, column=2, value=len(exceptions_df))
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font)
current_row += 1
if not exceptions_df.empty:
for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False)
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['A'].width = 45.0
ws.column_dimensions['B'].width = 38.0 ws.column_dimensions['B'].width = 38.0
@@ -324,23 +270,17 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
print(f"[✓] Ежедневная сводка сохранена: {output_path}") print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except PermissionError: except PermissionError:
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename) alt_path = os.path.join(OUTPUT_DIR, alt_filename)
wb.save(alt_path) wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- # --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
if not filename: if not filename:
filename = f"{format_date_ru(date_str)} отчет.xlsx" filename = f"{format_date_ru(date_str)} отчет.xlsx"
if merged_df is not None and not merged_df.empty: output_path = os.path.join(OUTPUT_DIR, filename)
df_export = merged_df[merged_df.get('is_excluded', False) == False].copy()
else:
df_export = pd.DataFrame()
target_dir = get_dated_reports_dir(date_str)
output_path = os.path.join(target_dir, filename)
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "Детальный_отчет" ws.title = "Детальный_отчет"
@@ -365,13 +305,13 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
apply_borders_to_cell(cell) apply_borders_to_cell(cell)
start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня' start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня'
end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня' end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня'
hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании' hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании'
chars_per_line_h = 24 chars_per_line_h = 24
for idx, row in df_export.reset_index(drop=True).iterrows(): for idx, row in merged_df.reset_index(drop=True).iterrows():
is_present = row.get('Пришел', False) is_present = row.get('Пришел', False)
absence_reason = row.get('Вид_отсутствия', '') absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
@@ -399,11 +339,13 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
row_num = 5 + idx row_num = 5 + idx
# ЗАЛИВКА СТРОК:
if is_present and has_reason: if is_present and has_reason:
row_fill = GREEN_FILL row_fill = GREEN_FILL
elif not is_present and has_reason: elif not is_present and has_reason:
row_fill = YELLOW_FILL row_fill = YELLOW_FILL
elif not is_present and not has_reason and not has_first_act: elif not is_present and not has_reason and not has_first_act:
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы)
row_fill = LIGHT_RED_FILL row_fill = LIGHT_RED_FILL
else: else:
row_fill = None row_fill = None
@@ -428,6 +370,7 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
else: else:
cell.alignment = Alignment(horizontal="left", vertical="center") cell.alignment = Alignment(horizontal="left", vertical="center")
# Динамический компактный автоподгон ширины колонок
for col in ws.columns: for col in ws.columns:
col_letter = get_column_letter(col[0].column) col_letter = get_column_letter(col[0].column)
max_len = 0 max_len = 0
@@ -448,11 +391,11 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
print(f"[✓] Детальный отчет сохранен: {output_path}") print(f"[✓] Детальный отчет сохранен: {output_path}")
except PermissionError: except PermissionError:
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename) alt_path = os.path.join(OUTPUT_DIR, alt_filename)
wb.save(alt_path) wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
output_path = os.path.join(REPORTS_DIR, filename) output_path = os.path.join(OUTPUT_DIR, filename)
df_scud.to_excel(output_path, index=False) df_scud.to_excel(output_path, index=False)
-84
View File
@@ -1,84 +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": []}
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):
# Первичная миграция из JSON в SQLite
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"] 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}")
+7 -16
View File
@@ -4,7 +4,7 @@ FILE: services/scud_etl/anomaly_detector.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl MODULE: services / scud_etl
ROLE: Детектирование истинных аномалий и конфликтов реестров. ROLE: Детектирование истинных аномалий и конфликтов реестров.
(Сотрудники из exceptions.json, удаленка и командировки исключены). (Приход удаленщиков и командированных в офис аномалией НЕ является).
=============================================================================== ===============================================================================
""" """
@@ -22,26 +22,17 @@ def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
- Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа). - Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа).
""" """
anomalies = [] anomalies = []
if df_merged is None or df_merged.empty:
return anomalies
for _, row in df_merged.iterrows(): for _, row in df_merged.iterrows():
# Пропускаем системные исключения
if row.get("is_excluded", False):
continue
fio = row.get("fio_clean") or row.get("Сотрудник", "") fio = row.get("fio_clean") or row.get("Сотрудник", "")
start_day = str(row.get("Начало_дня", "")).strip() start_day = str(row.get("Начало_дня", "")).strip()
end_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() reason_lower = reason.lower()
# Пропускаем технические метки # 1. Приход в офис при отпуске / больничном
if "исключен" in reason_lower or "овк" in reason_lower: if start_day != "Нет входа" and reason and reason != "nan":
continue # Удаленная работа и командировки разрешены для работы в офисе
# ⭐️ Физический приход в офис: удаленка и командировки разрешены и НЕ являются аномалией
if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan":
if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower): if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
anomalies.append({ anomalies.append({
"fio": fio, "fio": fio,
@@ -49,8 +40,8 @@ def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
"description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'." "description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'."
}) })
# Аномалия оборудования (есть выход без утреннего входа) # 2. Аномалия оборудования (есть выход без входа)
if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]: if start_day == "Нет входа" and end_day != "Нет выхода":
anomalies.append({ anomalies.append({
"fio": fio, "fio": fio,
"type": "SCUD_EQUIPMENT_ANOMALY", "type": "SCUD_EQUIPMENT_ANOMALY",
+52 -87
View File
@@ -1,15 +1,19 @@
""" """
=============================================================================== ===============================================================================
FILE: services/scud_etl/merger.py FILE: services/scud_etl/merger.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Агрегация реестров СКУД и 1С, наложение исключений, нормализация кодов
подразделений и расчет сходящегося баланса присутствия.
=============================================================================== ===============================================================================
""" """
import logging import logging
from typing import Dict, Any from typing import Dict, Any, List
import pandas as pd import pandas as pd
from services.knowledge.service import get_department_synonyms_dict # Используем правильные имена функций из services/knowledge/service.py
from config import normalize_fio, load_exceptions from services.knowledge.service import get_department_synonyms_dict, get_rules
logger = logging.getLogger("SCUD_MERGER") logger = logging.getLogger("SCUD_MERGER")
@@ -19,121 +23,82 @@ def merge_scud_and_1c(
df_staff_1c: pd.DataFrame, df_staff_1c: pd.DataFrame,
df_absences_1c: pd.DataFrame df_absences_1c: pd.DataFrame
) -> 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=[ Объединяет реестры СКУД и 1С:ЗУП:
'Сотрудник', 'fio_clean', 'Подразделение', 'Должность', - Применяет исключения (уборщики, ОВК и др.).
'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', - Устанавливает короткие аббревиатуры подразделений (КО, ОАН, РУК и др.).
'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', 'is_excluded' - Привязывает кадровые документы отсутствий из 1С.
]) """
# Получаем словарь синонимов
synonyms = get_department_synonyms_dict() synonyms = get_department_synonyms_dict()
exceptions_cfg = load_exceptions()
df_res = df_scud.copy() if df_scud is not None and not df_scud.empty else df_staff_1c.copy()
df_res = df_scud.copy()
if "Сотрудник" in df_res.columns: if "Сотрудник" in df_res.columns:
df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio) df_res["fio_clean"] = df_res["Сотрудник"].astype(str).str.strip()
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"] = ""
for col, default_val in [ # 1. Фильтрация системных исключений
('Начало_дня', 'Нет входа'), # Загружаем исключения из базы знаний или задаем системный фильтр
('Первая_активность', '—'), excluded_depts = {"ОВК", "Отдел вневедомственного контроля", "Служба уборки", "Клининг"}
('Конец_дня', 'Нет выхода'), excluded_positions = {"Уборщик", "Уборщица", "Дворник"}
('Находился_в_здании', '00:00'),
('Пришел', False),
('anomaly_flag', 'NONE')
]:
if col not in df_res.columns:
df_res[col] = default_val
# 1. Приведение отделов к аббревиатурам if not df_res.empty:
reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()} if "Подразделение" in df_res.columns:
direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()} df_res = df_res[~df_res["Подразделение"].astype(str).isin(excluded_depts)]
all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"} if "Должность" in df_res.columns:
df_res = df_res[~df_res["Должность"].astype(str).isin(excluded_positions)]
# 2. Трансляция подразделений в короткие аббревиатуры СКУД
if "Подразделение" in df_res.columns: if "Подразделение" in df_res.columns:
df_res["Подразделение"] = df_res["Подразделение"].apply( df_res["Подразделение"] = df_res["Подразделение"].apply(
lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) lambda d: synonyms.get(d, d)
) )
# 2. Привязка документов отсутствий из 1С ПЕРЕД разметкой исключений # 3. Привязка документов отсутствий из 1С
absences_map = {} absences_map = {}
if not df_absences_1c.empty: if not df_absences_1c.empty and "Сотрудник" in df_absences_1c.columns and "Причина" in df_absences_1c.columns:
fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None) for _, row in df_absences_1c.iterrows():
reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None) fio = str(row["Сотрудник"]).strip()
reason = str(row["Причина"]).strip()
if fio_col and reason_col: absences_map[fio] = reason
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":
absences_map[fio] = reason
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
# 3. Разметка исключений
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() != ""
# ⭐️ Если сотрудник в белом списке ИЛИ у него есть официальное отсутствие — он НЕ исключается
if fio in whitelist_fios or has_official_absence:
df_res.at[idx, "is_excluded"] = False
continue
dep = str(row.get("Подразделение", "")).upper()
pos = str(row.get("Должность", "")).lower()
if 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):
df_res.at[idx, "is_excluded"] = True
# Чистое "Исключение" только для истинно исключенных без документов 1С
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 return df_res
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
active_df = df_merged[df_merged.get("is_excluded", False) == False].copy() """
total_staff = len(active_df) Расчет строго сходящегося баланса присутствия:
A (Итого на работе) + B (Удаленная работа) + C (Официально отсутствуют) + D (Неизвестно) = N (Всего)
"""
total_staff = len(df_merged)
# 1. Строго физически пришедшие в офис # 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис)
came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа") came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")
working_in_office = active_df[came_to_office_mask] working_in_office = df_merged[came_to_office_mask]
working_in_office_count = len(working_in_office) working_in_office_count = len(working_in_office)
# 2. Не пришедшие в офис # 2. Все, кто сегодня НЕ пришел в офис
df_not_came = active_df[~came_to_office_mask] not_came_mask = ~came_to_office_mask
df_not_came = df_merged[not_came_mask]
# 3. Из не пришедших — удаленщики (работают из дома) # 3. Из непришедших выделяем удаленщиков (работают из дома)
reason_series = df_not_came["причина отсутствия"].astype(str).str.lower() reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home = df_not_came[is_remote_mask] remote_home = df_not_came[is_remote_mask]
remote_home_count = len(remote_home) remote_home_count = len(remote_home)
# 4. Из оставшихся — официальные отсутствия (отпуск, больничный, командировка) # 4. Из оставшихся непришедших выделяем официальные отсутствия (отпуск, больничный, командировка и т.д.)
df_remaining_absent = df_not_came[~is_remote_mask] df_remaining_absent = df_not_came[~is_remote_mask]
has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \
df_remaining_absent["причина отсутствия"].ne("") & \ df_remaining_absent["причина отсутствия"].ne("") & \
df_remaining_absent["причина отсутствия"].ne("nan") & \ df_remaining_absent["причина отсутствия"].ne("nan")
(~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение"))
official_absent_count = len(df_remaining_absent[has_doc_mask])
# 5. Неизвестные official_absent = df_remaining_absent[has_doc_mask]
official_absent_count = len(official_absent)
# 5. Оставшиеся непришедшие без документов — истинно неизвестные
unknown = df_remaining_absent[~has_doc_mask] unknown = df_remaining_absent[~has_doc_mask]
unknown_count = len(unknown) unknown_count = len(unknown)
+42 -35
View File
@@ -1,59 +1,66 @@
""" """
=============================================================================== ===============================================================================
FILE: services/scud_etl/pipeline.py FILE: services/scud_etl/pipeline.py
ROLE: Выборка данных СКУД из SQLite (scud_logs) с жестким приоритетом Y-снапшотов. PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Оркестрация выборки снапшотов из SQLite и загрузки кадровых файлов 1С.
=============================================================================== ===============================================================================
""" """
import os import os
import json
import logging import logging
from typing import Optional
import pandas as pd import pandas as pd
from core.connection import get_connection from core.connection import get_connection
from services.data_loader import load_1c_data_smart
logger = logging.getLogger("SCUD_PIPELINE") logger = logging.getLogger("SCUD_PIPELINE")
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame: def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
"""Извлекает срез СКУД. Для вчерашнего дня строго берет финальный вечерний срез Y.""" """
with get_connection() as conn: Извлекает срез за дату из SQLite. Для отчета за вчера строго ищет
df = pd.DataFrame() финальный вечерний срез Y (с зафиксированными выходами за 22:00:00).
"""
with get_connection(row_factory=True) as conn:
cursor = conn.cursor() cursor = conn.cursor()
if prefer_final_y: if prefer_final_y:
cursor.execute( cursor.execute("""
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", SELECT raw_data_json
(date_str,) 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))
row = cursor.fetchone() row = cursor.fetchone()
if row and row[0]: if row and row["raw_data_json"]:
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) data = json.loads(row["raw_data_json"])
return pd.DataFrame(data)
if df.empty: cursor.execute("""
cursor.execute( SELECT raw_data_json
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", FROM scud_snapshots
(date_str,) WHERE snapshot_date = ? OR date_str = ?
) ORDER BY id DESC LIMIT 1
row = cursor.fetchone() """, (date_str, date_str))
if row and row[0]: row = cursor.fetchone()
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) if row and row["raw_data_json"]:
data = json.loads(row["raw_data_json"])
return pd.DataFrame(data)
if not df.empty: return pd.DataFrame()
rename_map = {
'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник',
'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня',
'time_in_building': 'Находился_в_здании', 'is_present': 'Пришел'
}
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
if 'fio_clean' not in df.columns and 'Сотрудник' in df.columns:
df['fio_clean'] = df['Сотрудник'].astype(str).str.strip()
if 'Пришел' in df.columns:
df['Пришел'] = df['Пришел'].astype(bool)
return df
def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]: def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
df_staff, df_absences = load_1c_data_smart(date_str, use_db=False) """
return (df_staff if df_staff is not None else pd.DataFrame(), Загружает файлы Штат_*.xlsx и Отсутствия_*.xlsx за указанную дату из data/1c/.
df_absences if df_absences is not None else pd.DataFrame()) """
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
+1 -2
View File
@@ -28,7 +28,6 @@ LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
logger = logging.getLogger("scud_export") logger = logging.getLogger("scud_export")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False
if not logger.handlers: if not logger.handlers:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
@@ -53,7 +52,7 @@ def log(message: str, level: str = "INFO"):
logger.log(level_map.get(level, logging.INFO), message) 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" DATABASE_NAME = "Orion-14.01.21-1"
SQL_USER = "sa" SQL_USER = "sa"
SQL_PASSWORD = "123456" SQL_PASSWORD = "123456"
+76 -50
View File
@@ -1,6 +1,15 @@
""" """
=============================================================================== ===============================================================================
FILE: services/tasks/repository.py 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: def normalize_task_id(task_id_input: str) -> str:
"""Нормализует идентификатор задачи к формату TASK-XX."""
if not task_id_input: if not task_id_input:
return "" return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "") clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "")
@@ -19,36 +29,41 @@ def normalize_task_id(task_id_input: str) -> str:
return f"TASK-{clean_id}" return f"TASK-{clean_id}"
# ANCHOR[TASK_REPO_GET]
def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]: def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
with get_connection(row_factory=True) as conn: """Получает список задач пользователя с опциональной фильтрацией по статусу."""
cursor = conn.cursor() conn = get_connection(row_factory=True)
if status and status.upper() != "ALL": cursor = conn.cursor()
target_status = status.upper()
if target_status in ["PROGRESS", "В РАБОТЕ"]:
target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]:
target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ"]:
target_status = "BACKLOG"
cursor.execute(""" if status and status.upper() != "ALL":
SELECT id, task_id, module, title, priority, status, due_date, created_at target_status = status.upper()
FROM tasks if target_status in ["PROGRESS", "В РАБОТЕ"]:
WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED')) target_status = "IN_PROGRESS"
ORDER BY id DESC elif target_status in ["DONE", "ГОТОВО"]:
""", (user_id, target_status, target_status)) target_status = "COMPLETED"
else: elif target_status in ["PLANNED", "ПЛАНЫ"]:
cursor.execute(""" target_status = "BACKLOG"
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
""", (user_id,))
rows = cursor.fetchall() cursor.execute("""
return [dict(r) for r in rows] SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED'))
ORDER BY id DESC
""", (user_id, target_status, target_status))
else:
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
# ANCHOR[TASK_REPO_ADD]
def repo_add_task( def repo_add_task(
user_id: int, user_id: int,
module: str, module: str,
@@ -57,6 +72,13 @@ def repo_add_task(
due_date: Optional[str] = None, due_date: Optional[str] = None,
status: str = "BACKLOG" status: str = "BACKLOG"
) -> Dict[str, Any]: ) -> 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" target_status = status.upper() if status else "BACKLOG"
if target_status in ["PROGRESS", "В РАБОТЕ"]: if target_status in ["PROGRESS", "В РАБОТЕ"]:
target_status = "IN_PROGRESS" target_status = "IN_PROGRESS"
@@ -65,20 +87,16 @@ def repo_add_task(
elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]: elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]:
target_status = "BACKLOG" target_status = "BACKLOG"
with get_connection() as conn: cursor.execute("""
cursor = conn.cursor() INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
cursor.execute("SELECT MAX(id) FROM tasks") VALUES (?, ?, ?, ?, ?, ?, ?)
max_id = cursor.fetchone()[0] or 0 """, (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id))
new_task_id = f"TASK-{(max_id + 1):02d}" conn.commit()
conn.close()
cursor.execute(""" return {"status": "success", "task_id": new_task_id, "id": max_id + 1}
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()
return {"status": "success", "task_id": new_task_id, "id": max_id + 1}
# ANCHOR[TASK_REPO_UPDATE]
def repo_update_task( def repo_update_task(
user_id: int, user_id: int,
task_id: str, task_id: str,
@@ -87,6 +105,10 @@ def repo_update_task(
status: Optional[str] = None, status: Optional[str] = None,
due_date: Optional[str] = None due_date: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Комплексное обновление атрибутов задачи."""
conn = get_connection()
cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id)) clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(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) params.append(due_date.strip() if due_date.strip() else None)
if not updates: if not updates:
conn.close()
return {"status": "success", "message": "Нет данных для обновления"} return {"status": "success", "message": "Нет данных для обновления"}
params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id]) params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id])
@@ -125,11 +148,10 @@ def repo_update_task(
SET {', '.join(updates)} SET {', '.join(updates)}
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""" """
with get_connection() as conn: cursor.execute(sql, params)
cursor = conn.cursor() rows_affected = cursor.rowcount
cursor.execute(sql, params) conn.commit()
rows_affected = cursor.rowcount conn.close()
conn.commit()
if rows_affected == 0: if rows_affected == 0:
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
@@ -137,18 +159,22 @@ def repo_update_task(
return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"} return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"}
# ANCHOR[TASK_REPO_DELETE]
def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: 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)) clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id) formatted_id = normalize_task_id(task_id)
with get_connection() as conn: cursor.execute("""
cursor = conn.cursor() DELETE FROM tasks
cursor.execute(""" WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
DELETE FROM tasks """, (clean_num, formatted_id, f"%{task_id.strip()}", user_id))
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
deleted = cursor.rowcount conn.commit()
conn.commit() conn.close()
if deleted == 0: if deleted == 0:
return {"error": f"Задача {task_id} не найдена"} return {"error": f"Задача {task_id} не найдена"}
+26 -31
View File
@@ -6,6 +6,10 @@ from services.knowledge_base import load_knowledge_base
def find_python_fuzzy_matches(unexplained_df, raw_absent_df): def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
"""
Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ).
Сравнивает фамилию и полное имя с порогом сходства >= 0.75.
"""
if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty:
return [] return []
@@ -46,46 +50,40 @@ def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
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"): 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() kb = load_knowledge_base()
custom_rules = kb.get("rules", []) custom_rules = kb.get("rules", [])
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." 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) total_staff = len(merged_df)
is_working_mask = (merged_df['Пришел'] == True) | (
# 1. Пришедшие в офис merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
present_office_cnt = len(merged_df[merged_df['Пришел'] == True]) )
present_cnt = len(merged_df[is_working_mask])
# 2. Не пришедшие удаленщики explained_cnt = len(absent_explained)
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])
# 3. Официально отсутствующие (без удаленки)
explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))])
unexplained_cnt = len(absent_unexplained) unexplained_cnt = len(absent_unexplained)
anomalies_cnt = len(anomalies_list) if anomalies_list else 0 anomalies_cnt = len(anomalies_list) if anomalies_list else 0
# 3. Точный расчет неточных совпадений через Python
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
anomalies_formatted = [] # 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД
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 "Аномалий не обнаружено."
unexplained_list = [] unexplained_list = []
if not absent_unexplained.empty: if not absent_unexplained.empty:
for idx, r in absent_unexplained.iterrows(): for idx, r in absent_unexplained.iterrows():
fio = r.get('Сотрудник', r.get('fio_clean', '')) fio = r.get('Сотрудник', r.get('fio_clean', ''))
dept_scud = str(r.get('department_scud', '')).strip() dept_scud = str(r.get('department_scud', '')).strip()
dept_1c = str(r.get('Подразделение', '')).strip() dept_1c = str(r.get('Подразделение', '')).strip()
dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c
if not dept: if not dept:
dept = '—' dept = '—'
pos = r.get('Должность', '—') pos = r.get('Должность', '—')
unexplained_list.append(f"{fio} — {dept}, {pos}") unexplained_list.append(f"{fio} — {dept}, {pos}")
# 5. Сборка промпта для Ollama
prompt = f""" prompt = f"""
Ты — старший аудитор кадровой безопасности и контроллинга СКУД. Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
Сформируй итоговую сводку кадрового контроля на {date_str}. Сформируй итоговую сводку кадрового контроля на {date_str}.
@@ -95,15 +93,14 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
🚨 ВХОДНЫЕ МЕТРИКИ: 🚨 ВХОДНЫЕ МЕТРИКИ:
- Всего сотрудников: {total_staff} - Всего сотрудников: {total_staff}
- Итого на работе (в офисе): {present_office_cnt} - Работают (офис / удаленка / командировки): {present_cnt}
- В том числе на удаленной работе: {remote_home_cnt}
- Официально отсутствуют: {explained_cnt} - Официально отсутствуют: {explained_cnt}
- Неизвестно (истинно неотмеченные): {unexplained_cnt} - Неизвестно (истинно неотмеченные): {unexplained_cnt}
- Выявлено аномалий/конфликтов реестров: {anomalies_cnt} - Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
--- ---
🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт): 🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
{anomalies_text_block} {json.dumps(anomalies_list, ensure_ascii=False, indent=2)}
--- ---
⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт): ⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт):
@@ -114,34 +111,32 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)} {json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ: СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". Если аномалий нет, напиши: "Аномалий не обнаружено." 1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
2. В разделе "Подозрения на ошибки сопоставления ФИО" используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если он пуст, напиши: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." 2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
3. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. 3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций.
4. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы без перечисления конкретных ФИО.
СТРОГИЙ ШАБЛОН ОТВЕТА: СТРОГИЙ ШАБЛОН ОТВЕТА:
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}** **Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
- Всего офисных сотрудников: **{total_staff}** - Всего офисных сотрудников: **{total_staff}**
- Итого на работе (в офисе): **{present_office_cnt}** - Работают (офис / удаленка / командировки): **{present_cnt}**
- В том числе на удаленной работе: **{remote_home_cnt}**
- Официально отсутствуют: **{explained_cnt}** - Официально отсутствуют: **{explained_cnt}**
- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел. - Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. - Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): #### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
(Выведи нумерованный текстовый список аномалий или 'Аномалий не обнаружено.') (Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.")
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: #### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива ошибок ФИО. Если пуст — 'Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.') (Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.")
#### Неизвестные случаи: {unexplained_cnt} #### Неизвестные случаи: {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) 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: try:
conn_str = get_zup_connection_string() 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]) df = pd.read_sql(query, conn, params=[target_date])
if not df.empty: if not df.empty: