38 changed files with 1767 additions and 5547 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": []}
+8 -36
View File
@@ -21,7 +21,7 @@ def has_yesterday_final_snapshot(date_str: str) -> bool:
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00') LIMIT 1", "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
(date_str,) (date_str,)
) )
return cursor.fetchone() is not None return cursor.fetchone() is not None
@@ -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 '—'
+5 -30
View File
@@ -62,7 +62,7 @@ def init_all_tables() -> None:
); );
""") """)
# 3. Аномалии, база знаний и синонимы # 3. Аномалии и база знаний
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history ( CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -93,34 +93,7 @@ def init_all_tables() -> None:
); );
""") """)
# 4. Исключения и Кэш сопоставлений личностей (ИИ / Ручной) # 4. Узлы системного промпта и сессии
cursor.execute("""
CREATE TABLE IF NOT EXISTS exceptions_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
value TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category, value)
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI', -- 'AI', 'MANUAL', 'EXACT'
status TEXT DEFAULT 'ACTIVE', -- 'ACTIVE', 'PROPOSED', 'REJECTED'
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
# 5. Узлы системного промпта, сессии, сообщения и задачи
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS system_prompt_nodes ( CREATE TABLE IF NOT EXISTS system_prompt_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -154,6 +127,7 @@ def init_all_tables() -> None:
); );
""") """)
# 5. Задачи
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -171,6 +145,7 @@ def init_all_tables() -> None:
# 6. Индексы # 6. Индексы
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mapping_scud ON person_identity_mapping(scud_fio);") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
conn.commit() conn.commit()
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
+23 -61
View File
@@ -1,58 +1,6 @@
# 📋 История изменений (CHANGELOG) # 📋 История изменений (CHANGELOG)
Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле. Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6].
## [3.2.0] — 2026-08-27
### 🧠 Интеллектуальный арбитраж личностей, агрегация мульти-пропусков и автозакрытие смен
#### ✨ Добавлено:
- **Кэш сопоставлений личностей (`person_identity_mapping`)**:
- Создана таблица в SQLite (`core/schema.py`) для постоянного хранения подтвержденных связок между сырыми ФИО СКУД и эталонными ФИО 1С:ЗУП.
- Поддержка мгновенного извлечения проверенных сопоставлений в `merger.py` (время выборки < 1 мс).
- **Агрегация дубликатов пропусков и мульти-учеток СКУД (`merger.py`)**:
- Функция `aggregate_scud_by_person` объединяет все карты/записи одного физлица: фиксирует самый ранний вход `min(valid_ins)`, самый поздний выход `max(valid_outs)` и итоговый статус присутствия.
- Исключено ложное попадание сотрудников с несколькими пропусками (например, Кондрашова Е.В.) в категорию «неизвестно».
- **Интеллектуальный выбор ставки совместителей 1С (`merger.py`)**:
- Функция `select_best_zup_position` сопоставляет множественные должности одного сотрудника в 1С со СКУД и подтягивает ставку того подразделения, где человек находится физически.
- **Автозакрытие незакрытых смен по Правилу 8.5ч (`excel_exporter.py`)**:
- При наличии утреннего входа и отсутствии вечернего выхода у офисных сотрудников в детальном отчете за вчера расчетный выход ставится как `Вход + 8ч 30мин`, норма 8 часов и нулевое отклонение `0:00` (вместо ложного штрафа `-8:00`).
- **Теневой ИИ-арбитраж нераспознанных ФИО (`text_reporter.py`)**:
- Модуль `find_ai_identity_suggestions` выявляет нечеткие совпадения и опечатки операторов СКУД, выводя рекомендации для кадровой службы в блоке «💡 Предложения ИИ по сопоставлению ФИО».
- **Расширение CLI-утилиты (`scripts/db_cli.py`)**:
- Добавлены команды управления связками: `python scripts/db_cli.py mapping [list|add|del]`.
#### 🔧 Изменено:
- **`services/scud_etl/anomaly_detector.py`**:
- Добавлено авто-детектирование задвоенных карточек СКУД (`DUPLICATE_SCUD_CARD`) с передачей информации в ИИ-сводку.
- **`main_etl.py`**:
- Восстановлена передача сырых датасетов (`raw_scud_df`, `raw_staff_df`, `raw_absent_df`) в ИИ-аудитор и генерация диагностического дампа `СКУД_Сырые_данные_ДД.ММ.ГГГГ.xlsx`.
---
## [3.1.0] — 2026-08-25
### 🛡️ Реляционный реестр исключений, Белый список и отказоустойчивость выгрузок
#### ✨ Добавлено:
- **Реляционный реестр исключений (`exceptions_registry`)**:
- Перенос исключений (`departments`, `positions`, `fio`, `position_keywords`) и белого списка (`include_fio`) из статического JSON в базу данных SQLite (`core/schema.py`).
- Репозиторий `services/exceptions_repo.py` с функциями CRUD и авто-миграцией из `exceptions.json`.
- **Белый список (`include_fio`)**:
- Поддержка явного включения сотрудников в расчет контроллинга в обход должностных и отдельских фильтров (Тарасенко А.А., Журиков М.Н.).
- **Управление исключениями в CLI (`scripts/db_cli.py`)**:
- Добавлены команды `exceptions list`, `exceptions add -c ... -v ...`, `exceptions del`, `exceptions sync`.
- **Резервный загрузчик кадровых отсутствий (`data_loader.py`)**:
- Реализован каскадный fallback: `MS SQL 1C:ЗУП` (таймаут 30с) $\rightarrow$ резервный парсинг скопированных Excel-файлов `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`.
#### 🔧 Изменено:
- **Структура отчетов Excel (`excel_exporter.py`)**:
- Изменен базовый путь сохранения отчетов на `output/reports/ГОД/МЕСЯЦ/`.
- В Ежедневной сводке блок «В том числе на удаленной работе» вынесен под «Итого на работе», а блок «Исключения» размещен в самом низу с поддержкой группировки.
- Добавлена защита от блокировок Excel (`PermissionError`/`OSError`): при занятости файла создается резервная копия с временной меткой.
- **Подключение к СКУД (`services/scud_export.py`)**:
- Актуализирован адрес сервера MS SQL Орион (`172.16.200.147\SQL`).
- Добавлена защита инициализации файлового логгера от ошибок доступа прав пользователей.
---
## [3.0.0] — 2026-08-21 ## [3.0.0] — 2026-08-21
### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture) ### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture)
@@ -123,28 +71,42 @@
- Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`. - Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`.
* **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий. * **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий.
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**:
- Прямой просмотр промпта (`db_get_system_prompt`) переведен в прозрачный режим без блокировки строки ввода и без лишних кнопок.
- В инструмент `db_prompt_node_edit` добавлена передача исходного `baseline_prompt` для точного сопоставления правок в UI.
* **`modules/web_api/llm/core/fast_path.py`**: Оптимизирована зачистка эфемерных сообщений при кликах на кнопки фазы follow-up.
--- ---
## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling ## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling
### ✨ Добавлено ### ✨ Добавлено
* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`. * **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`[cite: 6].
* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling. * **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling[cite: 6].
### 🔧 Изменено
* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя[cite: 6].
* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога[cite: 6].
--- ---
## [2026-08-15] — Реляционная архитектура системного промпта ## [2026-08-15] — Реляционная архитектура системного промпта
### ✨ Добавлено ### ✨ Добавлено
* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`). * **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`)[cite: 6].
* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта. * **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта[cite: 6].
* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`. * **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`[cite: 6].
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite[cite: 6].
* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных[cite: 6].
--- ---
## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога ## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога
### ✨ Добавлено ### ✨ Добавлено
* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков. * **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков[cite: 6].
* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций. * **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций[cite: 6].
* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия. * **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия[cite: 6].
+76 -100
View File
@@ -1,58 +1,58 @@
# План реализации (ROADMAP) # План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]` ## 1. Очистка от регулярок и костылей (`tool_injector.py`)
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 3]. - [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 3]. - [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 4].
--- ---
## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]` ## 2. Настройка контекста и инструкций сессии (`agent.py`)
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 3]. - [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4].
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 3]. - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 3]. - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4].
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 3]. - [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4].
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 3]. - [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 4].
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 3]. - [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 4].
--- ---
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]` ## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`)
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 3]. - [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4].
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 3]. - [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4].
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 3]. - [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4].
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 3]. - [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 4].
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 3]. - [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 4].
--- ---
## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]` ## 4. Инлайн-редактор в окне диалога (`core.js`)
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 3]. - [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4].
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 3]. - [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4].
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 3]. - [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 4].
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 3]. - [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 4].
--- ---
## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]` ## 5. Тестирование и валидация системного промпта
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 3]. - [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4].
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 3]. - [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4].
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 3]. - [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4].
--- ---
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]` ## 6. Распространение архитектурного паттерна на модуль задач (`tasks`)
- [x] **Масштабирование UI задач:** - [x] **Масштабирование UI задач:**
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 3]. - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 4].
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 3]. - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 4].
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 3]. - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 4].
- [x] **Инлайн-редактирование карточки задачи:** - [x] **Инлайн-редактирование карточки задачи:**
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 3]. - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 4].
- Отображение даты создания задачи[cite: 3]. - Отображение даты создания задачи[cite: 4].
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 3]. - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 4].
- [x] **Детерминированный Fast-Path и двухфазное удаление:** - [x] **Детерминированный Fast-Path и двухфазное удаление:**
- Мгновенная смена статусов без задержек LLM[cite: 3]. - Мгновенная смена статусов без задержек LLM[cite: 4].
- Карточка подтверждения удаления с автоочисткой контекста[cite: 3]. - Карточка подтверждения удаления с автоочисткой контекста[cite: 4].
- [x] **Генерация отчетов задач в Markdown:** - [x] **Генерация отчетов задач в Markdown:**
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3]. - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3].
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3]. - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3].
@@ -61,9 +61,9 @@
--- ---
## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]` ## 7. Распространение на модуль снапшотов (`snapshots`)
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 3]. - [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 4].
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 3]. - [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 4].
- [x] **Интерактивный UI с чекбоксами и защитой срезов:** - [x] **Интерактивный UI с чекбоксами и защитой срезов:**
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3]. - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3].
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3]. - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3].
@@ -74,74 +74,50 @@
--- ---
## 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]` ## 🏗️ Раздел 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
- [x] **Выделение общего слоя ядра (`core/`):**
- Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов. - [x] **Шаг 1. Выделение общего слоя ядра (`core/`)**
- DDL-схемы таблиц и индексов (`core/schema.py`). - [x] Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов.
- Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`). - [x] DDL-схемы таблиц и индексов (`core/schema.py`).
- Фасад обратной совместимости (`core/database.py`). - [x] Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`).
- [x] **Декомпозиция предметных доменов (`services/`):** - [x] Фасад обратной совместимости (`core/database.py`).
- Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`). - [x] **Шаг 2. Декомпозиция предметных доменов (`services/`)**
- [x] **Рефакторинг ETL-конвейера (`services/scud_etl/`):** - [x] Сервис задач (`services/tasks/`): CRUD, валидация, markdown-экспорт (`exporter.py`).
- Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`. - [x] Сервис системного промпта (`services/prompts/`): сборка узлов, вычисление diff (`diff_engine.py`), пакетное удаление (`BATCH_DELETE`).
- [x] **Модульный ИИ-оркестратор (`modules/ai_engine/`):** - [x] Сервис снапшотов СКУД (`services/snapshots/`): безопасное удаление с защитой Y-снапшотов.
- Динамический строитель системного контекста (`context_builder.py`). - [x] Сервис базы знаний (`services/knowledge/`): правила арбитража и синонимы отделов.
- Изолированные обработчики инструментов в `handlers/`. - [x] **Шаг 3. Рефакторинг ETL-конвейера (`services/scud_etl/`)**
- [x] Оркестратор этапов контроллинга (`pipeline.py`).
- [x] Детектор аномалий и конфликтов реестров (`anomaly_detector.py`).
- [x] Агрегация проходов и наложение исключений (`merger.py`).
- [x] Информативное консольное логирование этапов в `main_etl.py`.
- [x] **Шаг 4. Модульный ИИ-оркестратор (`modules/ai_engine/`)**
- [x] Динамический строитель системного контекста (`context_builder.py`).
- [x] Изолированные обработчики инструментов (`handlers/task_handler.py`, `handlers/prompt_handler.py`, `handlers/snapshot_handler.py`).
- [x] Компактный диспетчер диалога (`agent.py`).
- [x] Двухконтурный семантический анализатор намерений (`tool_injector.py`).
- [x] **Шаг 5. Зачистка рудиментов и стабилизация**
- [x] Устранение дубликатов в `web_api/llm/db/`.
- [x] Очистка роутеров от прямых манипуляций с базой данных.
- [x] Исправление сигнатур Fast-Path перехватчиков.
--- ---
## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и автозакрытие 8.5ч `[ЗАВЕРШЕНО]` ## 📊 Раздел 9. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]`
- [x] **Реестр исключений в SQLite (`exceptions_registry`):**
- Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД. - [ ] **Интерактивный запуск отчетов из чата**
- Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`. - [ ] Инструмент `db_generate_report(date_str, report_type)` для генерации сводки или детального отчета по требованию оператора.
- [x] **Агрегация мульти-пропусков физлиц в СКУД:** - [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`).
- Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие). - [ ] **Диалоговый аудит расхождений**
- Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`). - [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера».
- [x] **Умный выбор ставки совместителей 1С:ЗУП:** - [ ] Быстрое внесение исключений и синонимов подразделений через диалог.
- Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД.
- [x] **Автозакрытие смен по Правилу 8.5ч:**
- Для забывших отметиться на выходе офисных сотрудников: автоматический расчет `Вход + 8ч 30мин`, норма 8 часов и отклонение `0:00` в `excel_exporter.py`.
- [x] **Кэш сопоставлений личностей (`person_identity_mapping`):**
- Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`.
- Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`.
- [x] **Каскадный fallback кадровых отсутствий:**
- `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`.
--- ---
## 10. Разграничение Сводки и Отчета + Почасовые срезы и Y-23:59:59 `[В РАБОТЕ]` ## 10. Изолированная песочница кода (Code Execution Sandbox Engine)
- [ ] **Корректировка финишного среза Y на 23:59:59:**
- [ ] Обновить время вечернего среза с `22:00:00` на `23:59:59` в `services/scud_export.py`, `scud_repo.py` и `pipeline.py`.
- [ ] **Разделение генераторов отчетов:**
- [ ] Создать `services/scud_etl/svodka_generator.py` (оперативная сводка, последний доступный срез или выбранный дневной).
- [ ] Создать `services/scud_etl/otchet_generator.py` (детальный отчет за прошлую смену, строго по единственному итоговому `Y`-снапшоту за 23:59:59).
- [ ] Исключить устаревший `report_generator.py`.
- [ ] **Параметризация `main_etl.py`:**
- [ ] Добавить поддержку аргументов `--date ДД.ММ.ГГГГ` и `--snapshot ID` для свободного запуска расчета за любой прошлый день.
- [ ] **Политика почасовых срезов (Hourly Snapshots) и retention-очистка:**
- [ ] Реализовать фоновое снятие часовых срезов в течение рабочего дня.
- [ ] Настроить ночную очистку промежуточных дневных срезов с сохранением полуденного (13:00) и финишного (`Y` 23:59:59).
- [ ] **Толерантный поиск срезов (Smart Snap-to-Grid, ±15–30 мин):**
- [ ] Поиск готового снапшота в SQLite при запросе оператора с возможностью мгновенного построения сводки без лишних запросов к MS SQL Орион.
- [ ] Формирование On-Demand запроса в MS SQL Орион при отсутствии подходящего среза.
---
## 11. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]`
- [ ] **Двухконтурный запуск ИИ:**
- [ ] Перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`.
- [ ] **Якорный табельный номер (TabNo Matching):**
- [ ] Извлечение `TabNo` из MS SQL Орион (`pList.TabNo`) и MS SQL 1С:ЗУП.
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite (`scud_logs`, `zup_staff`, `person_identity_mapping`).
- [ ] Защита от смены фамилий и опечаток через неизменяемый табельный номер.
- [ ] **Динамическая инвалидация кэша связок:**
- [ ] Автоматическая отбраковка записей кэша при увольнении сотрудника или несовпадении подразделения.
---
## 12. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
- [ ] **Docker/gVisor контур:** - [ ] **Docker/gVisor контур:**
- Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 3]. - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4].
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 3]. - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4].
- [ ] **Динамические Python/Pandas вычисления:** - [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 3]. - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 3]. - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4].
-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": [
"Тарасенко Александр Александрович",
"Журиков Михаил Николаевич"
] ]
} }
+23 -83
View File
@@ -8,64 +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.scud_etl.svodka_generator import generate_svodka_service from services.text_reporter.service import format_controlling_summary_markdown
from services.scud_etl.otchet_generator import generate_otchet_service
from services.text_reporter import generate_markdown_report
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 export_raw_scud 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("--date", type=str, default=None, help="Дата расчета в формате ДД.ММ.ГГГГ")
parser.add_argument("--time", type=str, default=None, help="Время среза для сводки (например, 14:30)")
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()
if args.date: today_str = now.strftime("%d.%m.%Y")
today_str = args.date.replace('_', '.') yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
dt_target = datetime.strptime(today_str, "%d.%m.%Y")
days_back = 3 if dt_target.weekday() == 0 else 1
yesterday_str = (dt_target - timedelta(days=days_back)).strftime("%d.%m.%Y")
else:
today_str = now.strftime("%d.%m.%Y")
if now.weekday() == 0:
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
else:
yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
# [Этап 0] Выгрузка свежих данных СКУД # [Этап 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(input_date=args.date, debug=args.debug, save_xlsx=True)
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}...")
@@ -75,55 +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)
if df_scud_today is not None and not df_scud_today.empty: # [Этап 3] Детальный отчет за вчера (на базе финального Y-снапшота)
export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx")
# [Этап 3] Детальный отчет за вчера через otchet_generator
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
res_otchet = generate_otchet_service(target_date=yesterday_str) df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
if res_otchet.get("status") == "success": build_detailed_yesterday_excel(df_merged_yesterday, yesterday_str)
print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}")
# [Этап 4] Сводка за сегодня через svodka_generator
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...")
res_svodka = generate_svodka_service(
target_date=today_str,
target_time=args.time,
snapshot_id=args.snapshot
)
if res_svodka.get("status") == "success":
print(f"[✓] {res_svodka.get('message')}: {res_svodka.get('filepath')}")
if res_svodka.get("note"):
print(f" ℹ️ {res_svodka.get('note')}")
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
# [Этап 4] Сводка за сегодня
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)
anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today) metrics_today = calculate_summary_metrics(df_merged_today)
anomalies_today = detect_registry_anomalies(df_merged_today)
build_daily_summary_excel(df_merged_today, metrics_today, today_str)
absent_explained = df_merged_today[ # [Этап 5] Формирование текстового отчета и вывод в консоль
(df_merged_today['Пришел'] == False) & print(f"\n[5/5] Формирование Markdown-сводки...")
(df_merged_today['Вид_отсутствия'].notna()) & summary_md = format_controlling_summary_markdown(today_str, metrics_today, anomalies_today)
(~df_merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_unexplained = df_merged_today[
(df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(df_merged_today.get('is_excluded', False) == False)
]
summary_md = generate_markdown_report(
merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False],
absent_explained=absent_explained,
absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=pd.DataFrame(),
anomalies_list=anomalies_today,
raw_scud_df=df_scud_today,
raw_staff_df=df_staff_today,
raw_absent_df=df_abs_today,
date_str=today_str
)
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 -175
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,54 +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 cmd_mapping(args_list):
"""Управление подтвержденными сопоставлениями ФИО (СКУД <-> 1С:ЗУП)."""
with get_connection() as conn:
cursor = conn.cursor()
if not args_list or args_list[0] in ["list", "show"]:
cursor.execute("SELECT id, scud_fio, zup_fio, match_source, status FROM person_identity_mapping ORDER BY id DESC")
rows = cursor.fetchall()
print("\n🔗 СОХРАНЕННЫЕ СОПОСТАВЛЕНИЯ ФИО (person_identity_mapping):")
print("=" * 80)
if rows:
for r in rows:
print(f" #{r[0]} [{r[4]}] СКУД: '{r[1]}' ⟷ 1С: '{r[2]}' ({r[3]})")
else:
print(" — сопоставлений пока нет")
print("=" * 80 + "\n")
return
subcmd = args_list[0]
if subcmd == "add":
if len(args_list) < 3:
print("Использование: python scripts/db_cli.py mapping add 'ФИО в СКУД' 'ФИО в 1С'")
return
scud_f, zup_f = args_list[1], args_list[2]
cursor.execute("""
INSERT OR REPLACE INTO person_identity_mapping (scud_fio, zup_fio, match_source, status)
VALUES (?, ?, 'MANUAL', 'ACTIVE')
""", (scud_f.strip(), zup_f.strip()))
conn.commit()
print(f"✅ Успешно добавлена связка: '{scud_f}' ⟷ '{zup_f}'")
elif subcmd in ["del", "delete", "remove"]:
if len(args_list) < 2:
print("Использование: python scripts/db_cli.py mapping del 'ФИО в СКУД'")
return
cursor.execute("DELETE FROM person_identity_mapping WHERE scud_fio = ?", (args_list[1].strip(),))
conn.commit()
print(f"✅ Связка для '{args_list[1]}' удалена.")
def print_tool_actions(): def print_tool_actions():
"""Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" """Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
print("\n" + "=" * 110) print("\n" + "=" * 110)
@@ -70,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)
@@ -100,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")
@@ -135,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)
@@ -155,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}")
@@ -188,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} строк.")
@@ -235,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):
@@ -299,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)
@@ -393,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()
@@ -417,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 '%добавлен пункт:%'
""" """
@@ -433,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)
@@ -469,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
@@ -488,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
""" """
@@ -506,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="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
@@ -531,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()
@@ -558,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':
@@ -572,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
)
+85 -133
View File
@@ -3,87 +3,52 @@ import os
import time import time
import openpyxl import openpyxl
import pandas as pd import pandas as pd
from datetime import datetime, timedelta 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):
date_clean = str(date_str).replace('_', '.') """Преобразует дату формата '27.07.2026' в '27 июля 2026'"""
try: try:
dt = datetime.strptime(date_clean, "%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): # Границы ячеек
date_clean = str(date_str).replace('_', '.')
try:
dt = datetime.strptime(date_clean, "%d.%m.%Y")
year_str = str(dt.year)
month_name = MONTHS_RU_NOMINATIVE[dt.month]
except Exception:
now = datetime.now()
year_str = str(now.year)
month_name = MONTHS_RU_NOMINATIVE[now.month]
target_dir = os.path.join(REPORTS_DIR, year_str, month_name)
os.makedirs(target_dir, exist_ok=True)
return target_dir
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")
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
# Палитра для Детального отчета
def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]: 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") # Потенциальные прогулы
⭐️ Правило 8.5ч: при наличии входа и отсутствии выхода GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске)
рассчитывает время выхода (Вход + 8ч 30мин) с нормой 8:00 и отклонением 0:00. LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска"
"""
try:
parts = time_in_str.strip().split(':')
hh = int(parts[0])
mm = int(parts[1]) if len(parts) > 1 else 0
ss = int(parts[2]) if len(parts) > 2 else 0
dt_in = datetime(2000, 1, 1, hh, mm, ss)
dt_out = dt_in + timedelta(hours=8, minutes=30)
return dt_out.strftime("%H:%M:%S"), "08:30", "0:00"
except Exception:
return "17:00:00", "08:30", "0:00"
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"
@@ -136,13 +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):
date_clean = str(date_str).replace('_', '.')
if not filename: if not filename:
filename = f"{format_date_ru(date_clean)} сводка.xlsx" filename = f"{format_date_ru(date_str)} сводка.xlsx"
target_dir = get_dated_reports_dir(date_clean) 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"
@@ -154,22 +117,26 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
bold_font = Font(name="Calibri", size=11, bold=True) bold_font = Font(name="Calibri", size=11, bold=True)
# 1. Шапка
ws.cell(row=1, column=1, value="Сводка на") ws.cell(row=1, column=1, value="Сводка на")
ws.cell(row=1, column=2, value=date_clean) ws.cell(row=1, column=2, value=date_str)
format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font)
apply_borders_to_cell(ws.cell(row=2, column=1)) apply_borders_to_cell(ws.cell(row=2, column=1))
apply_borders_to_cell(ws.cell(row=2, column=2)) apply_borders_to_cell(ws.cell(row=2, column=2))
# 2. По списку
ws.cell(row=3, column=1, value="По списку") ws.cell(row=3, column=1, value="По списку")
ws.cell(row=3, column=2, value=len(merged_df)) ws.cell(row=3, column=2, value=len(merged_df))
format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font) format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font)
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. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана")
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() == '')) &
@@ -188,7 +155,9 @@ 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. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок)
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="Нет пропуска")
ws.cell(row=current_row, column=2, value=len(no_pass_df)) ws.cell(row=current_row, column=2, value=len(no_pass_df))
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font)
@@ -202,15 +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
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() # 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
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('Вид_отсутствия')
@@ -230,7 +195,12 @@ 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
present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] # 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
)
present = merged_df[is_working_mask]
ws.cell(row=current_row, column=1, value="Итого на работе") ws.cell(row=current_row, column=1, value="Итого на работе")
ws.cell(row=current_row, column=2, value=len(present)) ws.cell(row=current_row, column=2, value=len(present))
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
@@ -244,26 +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
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] # 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
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
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')
) )
] ]
@@ -274,6 +228,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
current_row += 1 current_row += 1
chars_per_line_b = 30 chars_per_line_b = 30
if not anomalies.empty: if not anomalies.empty:
for _, row in anomalies.iterrows(): for _, row in anomalies.iterrows():
fio = row.get('Сотрудник', '') fio = row.get('Сотрудник', '')
@@ -288,29 +243,21 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}") cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text) cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY cell_a.fill = FILL_ANOMALY
cell_b.fill = FILL_ANOMALY cell_b.fill = FILL_ANOMALY
apply_borders_to_cell(cell_a) apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b) apply_borders_to_cell(cell_b)
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center") cell_a.alignment = Alignment(horizontal="left", vertical="center")
lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1 if len(reason_text) > chars_per_line_b:
ws.row_dimensions[current_row].height = max(lines_count * 18, 20) lines_count = math.ceil(len(reason_text) / chars_per_line_b)
ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].height = max(lines_count * 18, 22)
ws.row_dimensions[current_row].hidden = True else:
current_row += 1 ws.row_dimensions[current_row].height = 20
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].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
@@ -321,32 +268,25 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
try: try:
wb.save(output_path) wb.save(output_path)
print(f"[✓] Ежедневная сводка сохранена: {output_path}") print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except (PermissionError, OSError): 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. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ПРАВИЛОМ 8.5ч) --- # --- 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):
date_clean = str(date_str).replace('_', '.')
if not filename: if not filename:
filename = f"{format_date_ru(date_clean)} отчет.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_clean)
output_path = os.path.join(target_dir, filename)
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "Детальный_отчет" ws.title = "Детальный_отчет"
ws["B2"] = "Дата:" ws["B2"] = "Дата:"
ws["D2"] = date_clean ws["D2"] = date_str
ws["B2"].font = Font(name="Arial", size=10, bold=True) ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True) ws["D2"].font = Font(name="Arial", size=10, bold=True)
@@ -365,38 +305,32 @@ 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() != ''
anom_flag = row.get('anomaly_flag', 'NONE')
in_val = str(row.get(start_col, 'Нет входа')).strip()
out_val = str(row.get(end_col, 'Нет выхода')).strip()
in_building_str = str(row.get(hours_col, '00:00')) in_building_str = str(row.get(hours_col, '00:00'))
first_act_val = str(row.get('Первая_активность', '—')).strip() first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan'] has_first_act = first_act_val not in ['—', '', 'None', 'nan']
# ⭐️ ПРАВИЛО 8.5ч: если есть вход, но нет выхода (и нет официального документа отсутствия)
if in_val not in ['Нет входа', '—', '', 'nan', 'None'] and out_val in ['Нет выхода', '—', '', 'nan', 'None'] and not has_reason:
out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val)
else:
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
ws.append([ ws.append([
idx + 1, idx + 1,
row.get('Сотрудник', ''), row.get('Сотрудник', ''),
dept_scud_val, dept_scud_val,
in_val, row.get(start_col, 'Нет входа'),
first_act_val, first_act_val,
out_val, row.get(end_col, 'Нет выхода'),
in_building_str, in_building_str,
absence_reason if has_reason else '', absence_reason if has_reason else '',
8, 8,
@@ -404,24 +338,31 @@ 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
val_h_str = str(absence_reason) if has_reason else "" val_h_str = str(absence_reason) if has_reason else ""
lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1 if len(val_h_str) > chars_per_line_h:
ws.row_dimensions[row_num].height = max(lines_count * 18, 20) needed_lines = math.ceil(len(val_h_str) / chars_per_line_h)
ws.row_dimensions[row_num].height = max(needed_lines * 18, 22)
else:
ws.row_dimensions[row_num].height = 20
for col_idx in range(1, len(headers) + 1): for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=row_num, column=col_idx) cell = ws.cell(row=row_num, column=col_idx)
apply_borders_to_cell(cell) apply_borders_to_cell(cell)
if row_fill: if row_fill:
cell.fill = row_fill cell.fill = row_fill
if col_idx == 8: if col_idx == 8:
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
elif col_idx in [1, 4, 5, 6, 7, 9, 10]: elif col_idx in [1, 4, 5, 6, 7, 9, 10]:
@@ -429,21 +370,32 @@ 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 = max((len(str(cell.value or '')) for cell in col), default=8) max_len = 0
ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 35) for cell in col:
if cell.value is not None:
cell_lines = str(cell.value).split("\n")
line_max = max(len(line) for line in cell_lines)
if line_max > max_len:
max_len = line_max
optimal_width = max(max_len + 2, 8)
if optimal_width > 35:
optimal_width = 35
ws.column_dimensions[col_letter].width = optimal_width
try: try:
wb.save(output_path) wb.save(output_path)
print(f"[✓] Детальный отчет сохранен: {output_path}") print(f"[✓] Детальный отчет сохранен: {output_path}")
except (PermissionError, OSError): 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}")
+18 -38
View File
@@ -1,71 +1,51 @@
""" """
=============================================================================== ===============================================================================
FILE: services/scud_etl/anomaly_detector.py FILE: services/scud_etl/anomaly_detector.py
ROLE: Детектирование аномалий СКУД, дубликатов пропусков и несоответствий с 1С. PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Детектирование истинных аномалий и конфликтов реестров.
(Приход удаленщиков и командированных в офис аномалией НЕ является).
=============================================================================== ===============================================================================
""" """
import logging import logging
from typing import List, Dict, Any from typing import List, Dict, Any
import pandas as pd import pandas as pd
from config import normalize_fio
logger = logging.getLogger("SCUD_ANOMALY") logger = logging.getLogger("SCUD_ANOMALY")
def detect_registry_anomalies(df_merged: pd.DataFrame, df_raw_scud: pd.DataFrame = None) -> List[Dict[str, Any]]: def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
"""
Выявляет реальные аномалии:
- Приход в офис во время отпуска или больничного листа.
- Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа).
"""
anomalies = [] anomalies = []
if df_merged is None or df_merged.empty:
return anomalies
# 1. ⭐️ Поиск дубликатов учеток/пропусков в сыром СКУД
if df_raw_scud is not None and not df_raw_scud.empty:
df_raw = df_raw_scud.copy()
if 'fio_clean' not in df_raw.columns:
f_col = 'Сотрудник' if 'Сотрудник' in df_raw.columns else 'ФИО'
df_raw['fio_clean'] = df_raw[f_col].apply(normalize_fio)
counts = df_raw['fio_clean'].value_counts()
duplicate_fios = counts[counts > 1].index.tolist()
for dup_fio in duplicate_fios:
sub = df_raw[df_raw['fio_clean'] == dup_fio]
departments = ", ".join(sub['Подразделение'].astype(str).unique())
anomalies.append({
"fio": dup_fio,
"type": "DUPLICATE_SCUD_CARD",
"description": f"Сотрудник заведен в СКУД {len(sub)} раза (отделы: {departments}). События входа и выхода объединены автоматически."
})
# 2. Поиск кадровых аномалий и сбоев оборудования
for _, row in df_merged.iterrows(): 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()
if "исключен" in reason_lower or "овк" in reason_lower: # 1. Приход в офис при отпуске / больничном
continue if start_day != "Нет входа" and reason and reason != "nan":
# Удаленная работа и командировки разрешены для работы в офисе
# Приход в офис во время отпуска/больничного (удаленка и командировки разрешены)
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,
"type": "PHYSICAL_PRESENCE_DURING_ABSENCE", "type": "PHYSICAL_PRESENCE_DURING_ABSENCE",
"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",
"description": f"Зафиксирован выход ({end_day}) при отсутствии отметки утреннего входа." "description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})."
}) })
return anomalies return anomalies
+51 -201
View File
@@ -1,7 +1,10 @@
""" """
=============================================================================== ===============================================================================
FILE: services/scud_etl/merger.py FILE: services/scud_etl/merger.py
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки. PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Агрегация реестров СКУД и 1С, наложение исключений, нормализация кодов
подразделений и расчет сходящегося баланса присутствия.
=============================================================================== ===============================================================================
""" """
@@ -9,246 +12,93 @@ import logging
from typing import Dict, Any, List 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
from core.connection import get_connection
logger = logging.getLogger("SCUD_MERGER") logger = logging.getLogger("SCUD_MERGER")
def load_identity_mappings() -> Dict[str, str]:
"""Загружает подтвержденные сопоставления ФИО (СКУД -> 1С:ЗУП) из SQLite."""
with get_connection() as conn:
cursor = conn.cursor()
# Автоматическая инициализация таблицы при первом обращении
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI',
status TEXT DEFAULT 'ACTIVE',
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
cursor.execute("""
SELECT scud_fio, zup_fio
FROM person_identity_mapping
WHERE status = 'ACTIVE'
""")
return {r[0]: r[1] for r in cursor.fetchall()}
def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
"""Схлопывает дубликаты пропусков одного человека в СКУД."""
if df_scud is None or df_scud.empty:
return df_scud
df = df_scud.copy()
if 'fio_clean' not in df.columns:
fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО'
df['fio_clean'] = df[fio_col].apply(normalize_fio)
# Применяем сохраненный кэш связок ФИО
mapping_dict = load_identity_mappings()
if mapping_dict:
df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f))
aggregated_rows = []
for fio_clean, group in df.groupby('fio_clean'):
if len(group) == 1:
aggregated_rows.append(group.iloc[0].to_dict())
continue
base_row = group.sort_values(by='Пришел', ascending=False).iloc[0].to_dict()
valid_ins = [
str(t).strip() for t in group['Начало_дня']
if str(t).strip() not in ['Нет входа', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Начало_дня'] = min(valid_ins) if valid_ins else 'Нет входа'
valid_outs = [
str(t).strip() for t in group['Конец_дня']
if str(t).strip() not in ['Нет выхода', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Конец_дня'] = max(valid_outs) if valid_outs else 'Нет выхода'
valid_acts = [
str(t).strip() for t in group['Первая_активность']
if str(t).strip() not in ['—', '', 'nan', 'None', '00:00:00']
]
base_row['Первая_активность'] = min(valid_acts) if valid_acts else '—'
base_row['Пришел'] = any(group['Пришел'] == True) or (base_row['Начало_дня'] != 'Нет входа')
durations = [str(d) for d in group['Находился_в_здании'] if str(d) not in ['00:00', '', 'nan']]
if durations:
base_row['Находился_в_здании'] = max(durations)
aggregated_rows.append(base_row)
return pd.DataFrame(aggregated_rows)
def select_best_zup_position(df_staff_1c: pd.DataFrame, df_scud_agg: pd.DataFrame) -> pd.DataFrame:
"""
⭐️ Для совместителей с несколькими должностями в 1С:ЗУП
выбирает ту ставку, которая соответствует отделу физического нахождения по СКУД.
"""
if df_staff_1c is None or df_staff_1c.empty:
return pd.DataFrame()
df_staff = df_staff_1c.copy()
if 'fio_clean' not in df_staff.columns:
f_col = 'ФИО' if 'ФИО' in df_staff.columns else 'Сотрудник'
df_staff['fio_clean'] = df_staff[f_col].apply(normalize_fio)
scud_dept_map = {}
if df_scud_agg is not None and not df_scud_agg.empty:
for _, r in df_scud_agg.iterrows():
scud_dept_map[r.get('fio_clean', '')] = str(r.get('Подразделение', '')).strip().upper()
best_rows = []
for fio, group in df_staff.groupby('fio_clean'):
if len(group) == 1:
best_rows.append(group.iloc[0].to_dict())
continue
target_scud_dept = scud_dept_map.get(fio, "")
matched_row = None
if target_scud_dept:
for _, r in group.iterrows():
dept_1c = str(r.get('Подразделение', '')).strip().upper()
if dept_1c == target_scud_dept or target_scud_dept in dept_1c or dept_1c in target_scud_dept:
matched_row = r.to_dict()
break
if not matched_row:
matched_row = group.iloc[0].to_dict()
best_rows.append(matched_row)
return pd.DataFrame(best_rows)
def merge_scud_and_1c( def merge_scud_and_1c(
df_scud: pd.DataFrame, df_scud: pd.DataFrame,
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_scud_agg = aggregate_scud_by_person(df_scud)
df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg)
df_res = df_scud_agg.copy() if df_scud_agg is not None and not df_scud_agg.empty else df_staff_agg.copy()
df_res = df_scud.copy()
if "Сотрудник" in df_res.columns: 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
reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()} if not df_res.empty:
direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()} if "Подразделение" in df_res.columns:
all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"} df_res = df_res[~df_res["Подразделение"].astype(str).isin(excluded_depts)]
if "Должность" in df_res.columns:
df_res = df_res[~df_res["Должность"].astype(str).isin(excluded_positions)]
# 2. Трансляция подразделений в короткие аббревиатуры СКУД
if "Подразделение" in df_res.columns: 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)
) )
# 3. Привязка документов отсутствий из 1С
absences_map = {} absences_map = {}
if df_absences_1c is not None and 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["причина отсутствия"]
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d]
exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
whitelist_fios = [normalize_fio(f) for f in exceptions_cfg.get("include_fio", []) if f]
df_res["is_excluded"] = False
for idx, row in df_res.iterrows():
fio = row.get("fio_clean", "")
has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() not in ["", "nan", "None", "Исключение"]
if fio in whitelist_fios 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
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)
came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа") # 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис)
working_in_office = active_df[came_to_office_mask] came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")
working_in_office = df_merged[came_to_office_mask]
working_in_office_count = len(working_in_office) working_in_office_count = len(working_in_office)
df_not_came = active_df[~came_to_office_mask] # 2. Все, кто сегодня НЕ пришел в офис
not_came_mask = ~came_to_office_mask
df_not_came = df_merged[not_came_mask]
# 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. Из оставшихся непришедших выделяем официальные отсутствия (отпуск, больничный, командировка и т.д.)
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])
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)
-59
View File
@@ -1,59 +0,0 @@
"""
===============================================================================
FILE: services/scud_etl/otchet_generator.py
ROLE: Генератор Детального Отчета за прошлые смены (строго по итоговому Y-снапшоту).
===============================================================================
"""
import os
import logging
from typing import Dict, Any, Optional
import pandas as pd
from config import DATE_YESTERDAY
from core.database import load_scud_from_db_by_snapshot
from services.scud_etl.pipeline import load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c
from services.excel_exporter import generate_detailed_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("OTCHET_GENERATOR")
def generate_otchet_service(
target_date: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует детальный отчет за прошедшую смену:
- target_date: дата отчета (по умолчанию вчерашний рабочий день).
- snapshot_id: опциональный ID (по умолчанию выбирается итоговый вечерний срез Y).
"""
date_clean = str(target_date or DATE_YESTERDAY).replace('_', '.')
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Итоговый срез СКУД (Y) за {date_clean} не найден в базе данных."
}
df_staff, df_abs = load_1c_files_for_date(date_clean)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
filename = f"{format_date_ru(date_clean)} отчет.xlsx"
generate_detailed_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "OTCHET",
"date": date_clean,
"snapshot_id": snapshot_id or "AUTO_Y_FINAL",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"total_rows": len(df_merged),
"message": f"Детальный отчет за {date_clean} успешно сформирован."
}
+37 -35
View File
@@ -1,64 +1,66 @@
""" """
=============================================================================== ===============================================================================
FILE: services/scud_etl/pipeline.py FILE: services/scud_etl/pipeline.py
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, Dict, Any, Tuple 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 core.database import load_scud_from_db_by_snapshot
from config import DATA_DIR
from services.data_loader import load_staff_data, load_absent_data
logger = logging.getLogger("SCUD_PIPELINE") logger = logging.getLogger("SCUD_PIPELINE")
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) -> Optional[pd.DataFrame]: def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
""" """
Загружает наилучший срез СКУД за дату. Извлекает срез за дату из SQLite. Для отчета за вчера строго ищет
Если prefer_final_y=True — отдает предпочтение финишному Y (23:59:59). финальный вечерний срез Y (с зафиксированными выходами за 22:00:00).
""" """
with get_connection() as conn: with get_connection(row_factory=True) as conn:
cursor = conn.cursor() cursor = conn.cursor()
target_snap_id = None
if prefer_final_y: if prefer_final_y:
cursor.execute(""" cursor.execute("""
SELECT snapshot_id SELECT raw_data_json
FROM scud_logs FROM scud_snapshots
WHERE log_date = ? WHERE (snapshot_date = ? OR date_str = ?) AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '22:00%')
AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00')
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
""", (date_str,)) """, (date_str, date_str))
row = cursor.fetchone() row = cursor.fetchone()
if row: if row and row["raw_data_json"]:
target_snap_id = row[0] data = json.loads(row["raw_data_json"])
return pd.DataFrame(data)
if not target_snap_id: cursor.execute("""
cursor.execute(""" SELECT raw_data_json
SELECT snapshot_id FROM scud_snapshots
FROM scud_logs WHERE snapshot_date = ? OR date_str = ?
WHERE log_date = ? ORDER BY id DESC LIMIT 1
ORDER BY id DESC LIMIT 1 """, (date_str, date_str))
""", (date_str,)) row = cursor.fetchone()
row = cursor.fetchone() if row and row["raw_data_json"]:
if row: data = json.loads(row["raw_data_json"])
target_snap_id = row[0] return pd.DataFrame(data)
if not target_snap_id: return pd.DataFrame()
return None
return load_scud_from_db_by_snapshot(date_str, snapshot_param=target_snap_id)
def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]: def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
""" """
Загружает реестры штата и отсутствий 1С на указанную дату через data_loader. Загружает файлы Штат_*.xlsx и Отсутствия_*.xlsx за указанную дату из data/1c/.
""" """
df_staff = load_staff_data(date_str) formatted_date = date_str.replace(".", "_")
df_abs = load_absent_data(date_str) staff_file = f"data/1c/Штат_{formatted_date}.xlsx"
return df_staff, df_abs 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
-80
View File
@@ -1,80 +0,0 @@
"""
===============================================================================
FILE: services/scud_etl/svodka_generator.py
ROLE: Генератор Ежедневной Сводки (оперативный контроль, текущий срез).
===============================================================================
"""
import os
import logging
from typing import Dict, Any, Optional
import pandas as pd
from config import DATE_TODAY
from core.database import load_scud_from_db_by_snapshot
from services.scud_etl.pipeline import load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.snapshots.finder import find_or_create_snapshot_for_time
from services.excel_exporter import generate_summary_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("SVODKA_GENERATOR")
def generate_svodka_service(
target_date: Optional[str] = None,
target_time: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует оперативную сводку на указанную дату / время:
- target_date: дата сводки (по умолчанию сегодня).
- target_time: время среза (например '14:30').
- snapshot_id: точный ID среза.
"""
date_clean = str(target_date or DATE_TODAY).replace('_', '.')
applied_note = ""
# Если передано время, но не указан конкретный snapshot_id — ищем ближайший или запрашиваем экспорт
if target_time and not snapshot_id:
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=True)
snapshot_id = found_id
applied_note = note
if note:
logger.info(note)
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Срез СКУД за {date_clean} ({applied_note or snapshot_id or 'последний доступный'}) не найден в базе."
}
df_staff, df_abs = load_1c_files_for_date(date_clean)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
metrics = calculate_summary_metrics(df_merged)
anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud)
# Добавляем суффикс времени в имя файла, если сводка строилась на точный срез
time_suffix = f" на {target_time.replace(':', '-')}" if target_time else ""
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "SVODKA",
"date": date_clean,
"target_time": target_time,
"snapshot_id": snapshot_id or "AUTO_LATEST",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"metrics": metrics,
"anomalies_count": len(anomalies),
"note": applied_note,
"message": f"Ежедневная сводка на {date_clean} {target_time or ''} успешно сформирована."
}
+2 -3
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"
@@ -237,7 +236,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
continue continue
if is_yesterday: if is_yesterday:
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 23:59:59" snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00"
else: else:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-89
View File
@@ -1,89 +0,0 @@
"""
===============================================================================
FILE: services/snapshots/finder.py
ROLE: Поиск ближайшего снапшота в SQLite (Smart Snap-to-Grid) и On-Demand экспорт.
===============================================================================
"""
import logging
from datetime import datetime
from typing import Optional, Tuple
from core.connection import get_connection
from services.scud_export import run_export
logger = logging.getLogger("SNAPSHOT_FINDER")
def find_or_create_snapshot_for_time(
target_date_str: str,
target_time_str: str,
tolerance_minutes: int = 20,
allow_ondemand_export: bool = True
) -> Tuple[Optional[str], str]:
"""
Ищет ближайший срез за указанную дату и время (±tolerance_minutes).
Если не найден и allow_ondemand_export=True — запрашивает выгрузку из MS SQL на это время.
Возвращает: (snapshot_id, human_message)
"""
date_clean = target_date_str.replace('_', '.')
dt_target = datetime.strptime(f"{date_clean} {target_time_str}", "%d.%m.%Y %H:%M")
# 1. Поиск существующих снапшотов за эту дату в SQLite
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT DISTINCT snapshot_id, snapshot_time
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
""", (date_clean,))
rows = cursor.fetchall()
best_snapshot = None
min_diff_seconds = float('inf')
for snap_id, snap_time_str in rows:
try:
# Формат в базе: YYYY-MM-DD HH:MM:SS или DD.MM.YYYY HH:MM:SS
raw_time = str(snap_time_str).strip()
if '.' in raw_time.split()[0]:
dt_snap = datetime.strptime(raw_time, "%d.%m.%Y %H:%M:%S")
else:
dt_snap = datetime.strptime(raw_time, "%Y-%m-%d %H:%M:%S")
diff = abs((dt_snap - dt_target).total_seconds())
if diff < min_diff_seconds:
min_diff_seconds = diff
best_snapshot = (snap_id, snap_time_str, dt_snap)
except Exception:
continue
# Если найден срез в пределах допуска (по умолчанию 20 минут)
if best_snapshot and min_diff_seconds <= (tolerance_minutes * 60):
snap_id, snap_time, dt_s = best_snapshot
diff_mins = round(min_diff_seconds / 60)
return snap_id, f"Использован готовый срез {snap_id} за {dt_s.strftime('%H:%M')} (разница {diff_mins} мин)."
# 2. Если срез не найден и разрешен On-Demand экспорт из MS SQL Орион
if allow_ondemand_export:
logger.info(f"Снапшот на {date_clean} {target_time_str} не найден в SQLite. Запуск прямого среза из MS SQL...")
run_export(
input_date=date_clean,
debug=False,
save_xlsx=True
)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
ORDER BY id DESC LIMIT 1
""", (date_clean,))
row = cursor.fetchone()
if row:
return row[0], f"Создан новый срез {row[0]} из MS SQL на {target_time_str}."
return None, f"Срез на {date_clean} {target_time_str} не найден."
+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} не найдена"}
+60 -53
View File
@@ -5,75 +5,85 @@ from services.ai_verifier import ask_ollama
from services.knowledge_base import load_knowledge_base from services.knowledge_base import load_knowledge_base
def find_ai_identity_suggestions(unexplained_df, raw_staff_df): def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
""" """
⭐️ Теневой ИИ-арбитраж: ищет потенциальные опечатки операторов СКУД Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ).
среди нераспознанных сотрудников и формирует рекомендации для администратора. Сравнивает фамилию и полное имя с порогом сходства >= 0.75.
""" """
if unexplained_df.empty or raw_staff_df is None or raw_staff_df.empty: if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty:
return [] return []
staff_fios = raw_staff_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in raw_staff_df.columns else [] absent_dict = {}
unexp_fios = unexplained_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in unexplained_df.columns else [] for idx, r in raw_absent_df.iterrows():
raw_fio = str(r.get('ФИО', ''))
clean_fio = str(r.get('fio_clean', ''))
reason = str(r.get('Вид_отсутствия', ''))
if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']:
absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason}
suggestions = [] matches = []
for scud_fio in unexp_fios: unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else []
f_parts = scud_fio.split()
if not f_parts: for fio in unexplained_fios:
fio_parts = fio.split()
if not fio_parts:
continue continue
surname = f_parts[0].lower() surname = fio_parts[0].lower()
for staff_fio in staff_fios: for abs_clean, abs_info in absent_dict.items():
if staff_fio.lower().startswith(surname[:4]): abs_parts = abs_clean.split()
ratio = difflib.SequenceMatcher(None, scud_fio.lower(), staff_fio.lower()).ratio() if not abs_parts:
if 0.75 <= ratio < 1.0: continue
suggestions.append({ abs_surname = abs_parts[0].lower()
"scud_fio": scud_fio,
"suggested_zup_fio": staff_fio, if surname == abs_surname:
"confidence": round(ratio, 2) ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio()
if ratio >= 0.75:
matches.append({
"target_fio": fio,
"found_in_1c_raw": abs_info['raw_fio'],
"reason_1c": abs_info['reason']
}) })
break break
return suggestions return matches
def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"): 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)
present_office_cnt = len(merged_df[merged_df['Пришел'] == True]) is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower() )
is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False) present_cnt = len(merged_df[is_working_mask])
remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason]) explained_cnt = len(absent_explained)
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
ai_suggestions = find_ai_identity_suggestions(absent_unexplained, raw_staff_df) fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
anomalies_formatted = []
if anomalies_list:
for idx, a in enumerate(anomalies_list, 1):
anomalies_formatted.append(f"{idx}. {a.get('fio', 'Сотрудник')}: {a.get('description', '')}")
anomalies_text_block = "\n".join(anomalies_formatted) if anomalies_formatted else "Аномалий не обнаружено."
# 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД
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}.
@@ -83,53 +93,50 @@ 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)}
--- ---
💡 ПРЕДЛОЖЕНИЯ ИИ ПО СОПОСТАВЛЕНИЮ ФИО ({len(ai_suggestions)} шт): ⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт):
{json.dumps(ai_suggestions, ensure_ascii=False, indent=2)} {json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)}
--- ---
📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): 📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел):
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)} {json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
СТРОГИЕ ИНСТРУКЦИИ: СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". 1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
2. В разделе "Подозрения на ошибки сопоставления ФИО" выведи рекомендации по сопоставлению из массива "ПРЕДЛОЖЕНИЯ ИИ". Если массив пуст — напиши "Ошибок сопоставления ФИО не обнаружено." 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С:
(Список предложений сопоставления или 'Ошибок сопоставления ФИО не обнаружено.') (Выведи данные ИСКЛЮЧИТЕЛЬНО из массива 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: