feat(etl): stable pipeline, exception registry in SQLite, multi-pass aggregation and db_cli
This commit is contained in:
+30
-11
@@ -151,7 +151,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
if not unrecognized_scud_fios or not staff_fios:
|
||||
return {}
|
||||
|
||||
# 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен)
|
||||
# Исключаем точные совпадения
|
||||
staff_fios_set = set(staff_fios)
|
||||
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
|
||||
|
||||
@@ -163,7 +163,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
prompt = f"""
|
||||
Ты — кадровый аудитор безопасности СКУД.
|
||||
{rules_context}
|
||||
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
|
||||
В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)}
|
||||
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
|
||||
|
||||
СТРОГИЕ ПРАВИЛА:
|
||||
@@ -173,40 +173,59 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
4. В поле "warning" опиши обнаруженную опечатку.
|
||||
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
|
||||
|
||||
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
|
||||
|
||||
Формат ответа:
|
||||
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
|
||||
{{
|
||||
"verified_matches": [
|
||||
{{
|
||||
"scud_fio": "ФИО из СКУД",
|
||||
"staff_fio": "эталон ФИО из Штат 1С",
|
||||
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
|
||||
"warning": "Описание опечатки в СКУД"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
raw_response = ask_ollama(
|
||||
prompt,
|
||||
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
|
||||
system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
|
||||
)
|
||||
|
||||
mapping = {}
|
||||
if not raw_response:
|
||||
return mapping
|
||||
|
||||
# Фаза 1: Попытка прямого разбора с санитарной очисткой
|
||||
try:
|
||||
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
|
||||
if match:
|
||||
json_str = match.group(0)
|
||||
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
|
||||
# Убираем висячие запятые: {"a": 1,} -> {"a": 1}
|
||||
json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
|
||||
# Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
|
||||
json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
|
||||
|
||||
data = json.loads(json_str)
|
||||
for item in data.get("verified_matches", []):
|
||||
scud_f = item.get("scud_fio")
|
||||
staff_f = item.get("staff_fio")
|
||||
warn = item.get("warning", "Точное совпадение (без опечаток)")
|
||||
if scud_f and staff_f:
|
||||
if scud_f and staff_f and staff_f in staff_fios_set:
|
||||
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
|
||||
return mapping
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть)
|
||||
try:
|
||||
pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']'
|
||||
matches = re.findall(pattern, raw_response, re.DOTALL)
|
||||
for scud_f, staff_f in matches:
|
||||
scud_clean = scud_f.strip()
|
||||
staff_clean = staff_f.strip()
|
||||
if staff_clean in staff_fios_set:
|
||||
mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"}
|
||||
except Exception as e:
|
||||
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
|
||||
|
||||
print(f"[!] Ошибка резервного парсинга опечаток: {e}")
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
|
||||
+55
-156
@@ -1,11 +1,17 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/data_loader.py
|
||||
ROLE: Надежная загрузка штата и отсутствий (MS SQL ЗУП -> Резервный Excel).
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import warnings
|
||||
import pandas as pd
|
||||
from config import (
|
||||
normalize_fio, clean_scud_fio_light, load_exceptions,
|
||||
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
|
||||
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR, DATA_DIR
|
||||
)
|
||||
# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite
|
||||
from services.zup_extractor import fetch_zup_absences_from_sql
|
||||
from core.database import (
|
||||
load_scud_from_db_by_snapshot,
|
||||
@@ -16,23 +22,8 @@ from core.database import (
|
||||
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
|
||||
|
||||
|
||||
def is_excluded(fio, dept, pos, exceptions):
|
||||
"""Проверяет, входит ли сотрудник или должность в список исключений."""
|
||||
fio_clean = normalize_fio(fio)
|
||||
dept_str = str(dept).strip().upper() if pd.notna(dept) else ""
|
||||
pos_str = str(pos).strip().lower() if pd.notna(pos) else ""
|
||||
|
||||
if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]:
|
||||
return True
|
||||
if any(keyword in pos_str for keyword in exceptions["position_keywords"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def load_staff_data(date_str):
|
||||
"""
|
||||
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
|
||||
"""
|
||||
"""Загружает файл Штатного расписания 1С."""
|
||||
filepath = find_dated_file("Штат", date_str)
|
||||
if not filepath:
|
||||
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
|
||||
@@ -43,8 +34,6 @@ def load_staff_data(date_str):
|
||||
|
||||
try:
|
||||
df_raw = pd.read_excel(filepath, skiprows=8)
|
||||
|
||||
# Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность
|
||||
df_staff = df_raw.iloc[:, [1, 5, 12]].copy()
|
||||
df_staff.columns = ['ФИО', 'Подразделение', 'Должность']
|
||||
|
||||
@@ -57,30 +46,63 @@ def load_staff_data(date_str):
|
||||
return None
|
||||
|
||||
|
||||
def load_absent_from_excel(date_str):
|
||||
"""Резервное чтение файла Отсутствия_ДД_ММ_ГГГГ.xlsx."""
|
||||
filepath = find_dated_file("Отсутствия", date_str)
|
||||
if not filepath:
|
||||
return None
|
||||
|
||||
try:
|
||||
for skip in [0, 1, 2, 3, 4, 5, 8]:
|
||||
df_try = pd.read_excel(filepath, skiprows=skip)
|
||||
fio_col = None
|
||||
reason_col = None
|
||||
for col in df_try.columns:
|
||||
c_str = str(col).lower()
|
||||
if ('фио' in c_str or 'сотрудник' in c_str) and fio_col is None:
|
||||
fio_col = col
|
||||
if ('вид' in c_str or 'причина' in c_str or 'отсутств' in c_str) and reason_col is None:
|
||||
reason_col = col
|
||||
|
||||
if fio_col and reason_col:
|
||||
df_res = df_try[[fio_col, reason_col]].copy()
|
||||
df_res.columns = ['ФИО', 'Вид_отсутствия']
|
||||
df_res = df_res.dropna(subset=['ФИО', 'Вид_отсутствия'])
|
||||
df_res = df_res[~df_res['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО|ЛЕНМОРНИИПРОЕКТ', case=False, na=False)]
|
||||
df_res['fio_clean'] = df_res['ФИО'].apply(normalize_fio)
|
||||
print(f" [✓] Резервный Excel: загружено {len(df_res)} записей отсутствий из {os.path.basename(filepath)}")
|
||||
return df_res[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
|
||||
except Exception as e:
|
||||
print(f" [⚠️] Ошибка чтения резервного Excel отсутствий {filepath}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_absent_data(date_str):
|
||||
"""
|
||||
Загружает документально подтвержденные отсутствия сотрудников
|
||||
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату,
|
||||
а также автоматически обогащает их удаленщиками из static_reason_workers.csv.
|
||||
Загружает отсутствия из MS SQL 1С:ЗУП, а при сбое — из локального Excel.
|
||||
"""
|
||||
df_absent = None
|
||||
try:
|
||||
df_absent = fetch_zup_absences_from_sql(date_str)
|
||||
|
||||
if df_absent is not None and not df_absent.empty:
|
||||
print(f" [✓] MS SQL ЗУП: получено {len(df_absent)} записей отсутствий за {date_str}")
|
||||
df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия'])
|
||||
df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)]
|
||||
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
|
||||
df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
|
||||
else:
|
||||
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
|
||||
except Exception as e:
|
||||
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
|
||||
print(f" [⚠️] Ошибка подключения к MS SQL ЗУП за {date_str}: {e}")
|
||||
|
||||
# Резервный источник: Excel файл из 1С с сетевой шары
|
||||
if df_absent is None or df_absent.empty:
|
||||
df_absent = load_absent_from_excel(date_str)
|
||||
|
||||
if df_absent is None:
|
||||
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
|
||||
|
||||
# ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv
|
||||
# Обогащение удаленщиками из static_reason_workers.csv
|
||||
try:
|
||||
from config import DATA_DIR
|
||||
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
|
||||
if os.path.exists(static_path):
|
||||
df_static = pd.read_csv(static_path, encoding='utf-8')
|
||||
@@ -97,18 +119,14 @@ def load_absent_data(date_str):
|
||||
})
|
||||
if new_rows:
|
||||
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:
|
||||
print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}")
|
||||
print(f" [⚠️] Ошибка чтения static_reason_workers.csv: {e}")
|
||||
|
||||
return df_absent if not df_absent.empty else None
|
||||
|
||||
|
||||
def load_1c_data_smart(date_str, use_db=False):
|
||||
"""
|
||||
Универсальный загрузчик данных 1С (Штат и Отсутствия).
|
||||
Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД
|
||||
за целевую дату указанного снапшота.
|
||||
"""
|
||||
df_staff = None
|
||||
df_absent = None
|
||||
|
||||
@@ -116,128 +134,9 @@ def load_1c_data_smart(date_str, use_db=False):
|
||||
df_staff = load_staff_from_db(date_str)
|
||||
df_absent = load_absences_from_db(date_str)
|
||||
|
||||
# Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL
|
||||
if df_staff is None:
|
||||
df_staff = load_staff_data(date_str)
|
||||
if df_absent is None:
|
||||
df_absent = load_absent_data(date_str)
|
||||
|
||||
return df_staff, df_absent
|
||||
|
||||
|
||||
def process_scud_anomalies(df_scud, exceptions=None):
|
||||
"""
|
||||
Вычисляет 'Первую активность' и маркирует аномалии СКУД.
|
||||
"""
|
||||
if exceptions is None:
|
||||
exceptions = load_exceptions()
|
||||
|
||||
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
|
||||
end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None)
|
||||
status_col = 'Статус' if 'Статус' in df_scud.columns else None
|
||||
|
||||
first_activities = []
|
||||
anomaly_flags = []
|
||||
is_present_flags = []
|
||||
|
||||
for _, row in df_scud.iterrows():
|
||||
fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip()
|
||||
fio_clean = normalize_fio(fio)
|
||||
dept = str(row.get('Подразделение', '')).strip()
|
||||
pos = str(row.get('Должность', '')).strip()
|
||||
|
||||
val_start = str(row.get(start_col, '')).strip() if start_col else ''
|
||||
val_end = str(row.get(end_col, '')).strip() if end_col else ''
|
||||
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
|
||||
|
||||
raw_first_event = row.get('raw_first_event', None)
|
||||
if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']:
|
||||
raw_first_event = None
|
||||
|
||||
has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00'])
|
||||
has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00'])
|
||||
|
||||
first_act = '—'
|
||||
anom_flag = 'NONE'
|
||||
is_present = not ('отсутствовал' in val_status or has_no_in)
|
||||
|
||||
if has_no_in:
|
||||
if raw_first_event is not None or not has_no_out:
|
||||
first_act = str(raw_first_event) if raw_first_event else val_end
|
||||
anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY'
|
||||
|
||||
if not is_excluded(fio, dept, pos, exceptions):
|
||||
is_present = False
|
||||
else:
|
||||
first_act = '—'
|
||||
is_present = False
|
||||
|
||||
first_activities.append(first_act)
|
||||
anomaly_flags.append(anom_flag)
|
||||
is_present_flags.append(is_present)
|
||||
|
||||
df_scud['Первая_активность'] = first_activities
|
||||
df_scud['anomaly_flag'] = anomaly_flags
|
||||
df_scud['Пришел'] = is_present_flags
|
||||
|
||||
return df_scud
|
||||
|
||||
|
||||
def load_scud_data(target_date_type="today", snapshot_param=None):
|
||||
"""
|
||||
Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite.
|
||||
"""
|
||||
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
|
||||
|
||||
df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param)
|
||||
|
||||
if df_scud is None or df_scud.empty:
|
||||
df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param)
|
||||
if df_scud is None or df_scud.empty:
|
||||
print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.")
|
||||
return pd.DataFrame()
|
||||
|
||||
exceptions = load_exceptions()
|
||||
|
||||
if 'fio_clean' not in df_scud.columns:
|
||||
fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0])
|
||||
df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str)
|
||||
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
|
||||
|
||||
if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns:
|
||||
df_scud['Начало_дня'] = df_scud['time_in']
|
||||
if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns:
|
||||
df_scud['Конец_дня'] = df_scud['time_out']
|
||||
if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns:
|
||||
df_scud['Первая_активность'] = df_scud['first_activity']
|
||||
if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns:
|
||||
df_scud['Пришел'] = df_scud['is_present'].astype(bool)
|
||||
|
||||
df_scud['is_excluded'] = df_scud.apply(
|
||||
lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions),
|
||||
axis=1
|
||||
)
|
||||
return df_scud
|
||||
|
||||
|
||||
def load_all_data():
|
||||
"""
|
||||
Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С.
|
||||
"""
|
||||
df_scud_today = load_scud_data(target_date_type="today")
|
||||
try:
|
||||
df_scud_yesterday = load_scud_data(target_date_type="yesterday")
|
||||
except FileNotFoundError:
|
||||
df_scud_yesterday = df_scud_today.copy()
|
||||
|
||||
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY)
|
||||
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY)
|
||||
|
||||
return (
|
||||
df_scud_today,
|
||||
df_scud_yesterday,
|
||||
df_staff_today,
|
||||
df_absent_today,
|
||||
df_staff_yesterday,
|
||||
df_absent_yesterday
|
||||
)
|
||||
return df_staff, df_absent
|
||||
+98
-41
@@ -7,48 +7,65 @@ from datetime import datetime
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from config import OUTPUT_DIR
|
||||
from config import REPORTS_DIR
|
||||
|
||||
# Словарь месяцев для текстового формата в названиях файлов
|
||||
MONTHS_RU = {
|
||||
MONTHS_RU_GENITIVE = {
|
||||
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
|
||||
5: "мая", 6: "июня", 7: "июля", 8: "августа",
|
||||
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
|
||||
}
|
||||
|
||||
MONTHS_RU_NOMINATIVE = {
|
||||
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
|
||||
5: "май", 6: "июнь", 7: "июль", 8: "август",
|
||||
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь"
|
||||
}
|
||||
|
||||
|
||||
def format_date_ru(date_str):
|
||||
"""Преобразует дату формата '27.07.2026' в '27 июля 2026'"""
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%d.%m.%Y")
|
||||
return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}"
|
||||
return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}"
|
||||
except Exception:
|
||||
return date_str
|
||||
|
||||
|
||||
# Границы ячеек
|
||||
def get_dated_reports_dir(date_str):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%d.%m.%Y")
|
||||
year_str = str(dt.year)
|
||||
month_name = MONTHS_RU_NOMINATIVE[dt.month]
|
||||
except Exception:
|
||||
now = datetime.now()
|
||||
year_str = str(now.year)
|
||||
month_name = MONTHS_RU_NOMINATIVE[now.month]
|
||||
|
||||
target_dir = os.path.join(REPORTS_DIR, year_str, month_name)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
return target_dir
|
||||
|
||||
|
||||
THIN_SIDE = Side(border_style="thin", color="D3D3D3")
|
||||
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE)
|
||||
|
||||
# Палитра заливки для Сводки
|
||||
FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
||||
FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
|
||||
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
|
||||
FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", 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", "E8F8F5", "FCF3CF"]
|
||||
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"]
|
||||
|
||||
# Палитра для Детального отчета
|
||||
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия
|
||||
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы
|
||||
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске)
|
||||
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска"
|
||||
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
|
||||
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
|
||||
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid")
|
||||
|
||||
|
||||
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
|
||||
"""Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин"""
|
||||
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
|
||||
return "0:00"
|
||||
|
||||
@@ -101,11 +118,12 @@ def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_f
|
||||
|
||||
|
||||
# --- 1. СВОДКА НА СЕГОДНЯ ---
|
||||
def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_str)} сводка.xlsx"
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
target_dir = get_dated_reports_dir(date_str)
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Лист_1"
|
||||
@@ -132,11 +150,10 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
|
||||
current_row = 4
|
||||
|
||||
# Подготовка флагов для точной фильтрации
|
||||
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
|
||||
is_exc = merged_df.get('is_excluded', False) == True
|
||||
|
||||
# 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана")
|
||||
# 3. НЕИЗВЕСТНО
|
||||
unexplained = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
||||
@@ -155,7 +172,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = False
|
||||
current_row += 1
|
||||
|
||||
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок)
|
||||
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА
|
||||
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
|
||||
|
||||
ws.cell(row=current_row, column=1, value="Нет пропуска")
|
||||
@@ -171,11 +188,16 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = False
|
||||
current_row += 1
|
||||
|
||||
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
|
||||
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (без удаленки и исключений)
|
||||
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
|
||||
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
|
||||
|
||||
absent_only = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
|
||||
(~is_remote_reason) &
|
||||
(~is_exc)
|
||||
]
|
||||
absent_groups = absent_only.groupby('Вид_отсутствия')
|
||||
|
||||
@@ -195,11 +217,8 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
|
||||
is_working_mask = (merged_df['Пришел'] == True) | (
|
||||
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
|
||||
)
|
||||
present = merged_df[is_working_mask]
|
||||
# 6. ИТОГО НА РАБОТЕ (Строго физически пришедшие в офис по СКУД)
|
||||
present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
|
||||
|
||||
ws.cell(row=current_row, column=1, value="Итого на работе")
|
||||
ws.cell(row=current_row, column=2, value=len(present))
|
||||
@@ -214,10 +233,29 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
|
||||
# 6.1. В ТОМ ЧИСЛЕ НА УДАЛЕННОЙ РАБОТЕ (Удаленщики из дома, которых нет в офисе)
|
||||
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
|
||||
|
||||
ws.cell(row=current_row, column=1, value="В том числе на удаленной работе")
|
||||
ws.cell(row=current_row, column=2, value=len(remote_home))
|
||||
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font)
|
||||
current_row += 1
|
||||
|
||||
if not remote_home.empty:
|
||||
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
|
||||
ws.cell(row=current_row, column=1, value=fio)
|
||||
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False)
|
||||
ws.row_dimensions[current_row].outlineLevel = 1
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# 7. АНОМАЛИИ СКУД И 1С
|
||||
anomalies = merged_df[
|
||||
(~is_exc) & (
|
||||
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) |
|
||||
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
|
||||
(~is_remote_reason) &
|
||||
(~reason_clean.str.contains('командировк', na=False))) |
|
||||
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
|
||||
)
|
||||
]
|
||||
@@ -262,6 +300,22 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# ⭐️ 8. РАЗДЕЛ: ИСКЛЮЧЕНИЯ (В самом низу, раскрывающийся список)
|
||||
exceptions_df = merged_df[is_exc]
|
||||
|
||||
ws.cell(row=current_row, column=1, value="Исключения")
|
||||
ws.cell(row=current_row, column=2, value=len(exceptions_df))
|
||||
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font)
|
||||
current_row += 1
|
||||
|
||||
if not exceptions_df.empty:
|
||||
for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()):
|
||||
ws.cell(row=current_row, column=1, value=fio)
|
||||
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False)
|
||||
ws.row_dimensions[current_row].outlineLevel = 1
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
ws.column_dimensions['A'].width = 45.0
|
||||
ws.column_dimensions['B'].width = 38.0
|
||||
|
||||
@@ -270,17 +324,23 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
|
||||
except PermissionError:
|
||||
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
|
||||
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
|
||||
alt_path = os.path.join(target_dir, alt_filename)
|
||||
wb.save(alt_path)
|
||||
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
|
||||
|
||||
|
||||
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
|
||||
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_str)} отчет.xlsx"
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
if merged_df is not None and not merged_df.empty:
|
||||
df_export = merged_df[merged_df.get('is_excluded', False) == False].copy()
|
||||
else:
|
||||
df_export = pd.DataFrame()
|
||||
|
||||
target_dir = get_dated_reports_dir(date_str)
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Детальный_отчет"
|
||||
@@ -305,13 +365,13 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
apply_borders_to_cell(cell)
|
||||
|
||||
start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня'
|
||||
end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня'
|
||||
hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании'
|
||||
start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
|
||||
end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
|
||||
hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании'
|
||||
|
||||
chars_per_line_h = 24
|
||||
|
||||
for idx, row in merged_df.reset_index(drop=True).iterrows():
|
||||
for idx, row in df_export.reset_index(drop=True).iterrows():
|
||||
is_present = row.get('Пришел', False)
|
||||
absence_reason = row.get('Вид_отсутствия', '')
|
||||
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
|
||||
@@ -339,13 +399,11 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
|
||||
row_num = 5 + idx
|
||||
|
||||
# ЗАЛИВКА СТРОК:
|
||||
if is_present and has_reason:
|
||||
row_fill = GREEN_FILL
|
||||
elif not is_present and has_reason:
|
||||
row_fill = YELLOW_FILL
|
||||
elif not is_present and not has_reason and not has_first_act:
|
||||
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы)
|
||||
row_fill = LIGHT_RED_FILL
|
||||
else:
|
||||
row_fill = None
|
||||
@@ -370,7 +428,6 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
else:
|
||||
cell.alignment = Alignment(horizontal="left", vertical="center")
|
||||
|
||||
# Динамический компактный автоподгон ширины колонок
|
||||
for col in ws.columns:
|
||||
col_letter = get_column_letter(col[0].column)
|
||||
max_len = 0
|
||||
@@ -391,11 +448,11 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
print(f"[✓] Детальный отчет сохранен: {output_path}")
|
||||
except PermissionError:
|
||||
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
|
||||
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
|
||||
alt_path = os.path.join(target_dir, alt_filename)
|
||||
wb.save(alt_path)
|
||||
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
|
||||
|
||||
|
||||
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
output_path = os.path.join(REPORTS_DIR, filename)
|
||||
df_scud.to_excel(output_path, index=False)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
===============================================================================
|
||||
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}")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/knowledge/service.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / knowledge
|
||||
ROLE: Доменный сервис базы знаний, правил компании и синонимов подразделений.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
from core.repositories.zup_repo import (
|
||||
get_all_rules_from_db,
|
||||
add_rule_to_db,
|
||||
get_department_synonyms_dict,
|
||||
add_department_synonym_to_db
|
||||
)
|
||||
|
||||
|
||||
def get_rules() -> List[Dict[str, Any]]:
|
||||
"""Получить все правила базы знаний в виде списка словарей."""
|
||||
raw_rules = get_all_rules_from_db()
|
||||
return [{"id": idx, "rule_text": r} for idx, r in enumerate(raw_rules, 1)]
|
||||
|
||||
|
||||
def add_rule(rule_text: str, added_by: str = "Human") -> None:
|
||||
"""Добавить новое правило в базу знаний."""
|
||||
add_rule_to_db(rule_text, added_by=added_by)
|
||||
|
||||
|
||||
def get_synonyms() -> Dict[str, str]:
|
||||
"""Получить словарь синонимов отделов."""
|
||||
return get_department_synonyms_dict()
|
||||
|
||||
|
||||
def register_department_synonym(short_name: str, full_name: str) -> None:
|
||||
"""Сохранить новую пару синонимов подразделения."""
|
||||
add_department_synonym_to_db(short_name, full_name)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/prompts/diff_engine.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / prompts
|
||||
ROLE: Формирование превью изменений промпта и генерация визуального HTML-Diff.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[PROMPT_DIFF_BUILDER]: Генерация черновика и HTML-разметки изменений.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Tuple, Dict, List, Set
|
||||
from core.connection import get_connection
|
||||
|
||||
def build_prompt_diff(
|
||||
action: str,
|
||||
section_id: int = None,
|
||||
item_id: int = None,
|
||||
content: str = "",
|
||||
delete_nodes: List[Tuple[int, int]] = None,
|
||||
prompt_name: str = "main_agent"
|
||||
) -> Tuple[str, str]:
|
||||
act = (action or "ADD").upper()
|
||||
nodes_to_delete: Set[Tuple[int, int]] = set()
|
||||
|
||||
if delete_nodes:
|
||||
nodes_to_delete.update(delete_nodes)
|
||||
elif act == "DELETE" and section_id is not None and item_id is not None:
|
||||
nodes_to_delete.add((section_id, item_id))
|
||||
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT section_id, item_id, content
|
||||
FROM system_prompt_nodes
|
||||
WHERE prompt_name = ? AND is_active = 1
|
||||
ORDER BY section_id, item_id
|
||||
""", (prompt_name,))
|
||||
existing_nodes = cursor.fetchall()
|
||||
|
||||
nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes}
|
||||
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k not in nodes_to_delete}
|
||||
|
||||
if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None:
|
||||
nodes_dict_for_draft[(section_id, item_id)] = content
|
||||
|
||||
draft_lines = []
|
||||
curr_sec = None
|
||||
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None: draft_lines.append("")
|
||||
draft_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
draft_lines.append(f" {s_id}.{i_id}. {txt}")
|
||||
merged_prompt = "\n".join(draft_lines)
|
||||
|
||||
diff_lines = []
|
||||
curr_sec = None
|
||||
display_nodes = dict(nodes_dict)
|
||||
if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None:
|
||||
display_nodes[(section_id, item_id)] = content
|
||||
|
||||
for (s_id, i_id), txt in sorted(display_nodes.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None: diff_lines.append("")
|
||||
diff_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
if (s_id, i_id) in nodes_to_delete:
|
||||
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
|
||||
elif act == "ADD" and s_id == section_id and i_id == item_id:
|
||||
line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
|
||||
else:
|
||||
line_str = f" {s_id}.{i_id}. {txt}"
|
||||
diff_lines.append(line_str)
|
||||
|
||||
return merged_prompt, "\n".join(diff_lines)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/prompts/repository.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / prompts
|
||||
ROLE: Реляционное хранилище узлов системного промпта (таблица system_prompt_nodes).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[PROMPT_REPO_GET_ACTIVE]: Сборка активного промпта из узлов БД.
|
||||
- ANCHOR[PROMPT_REPO_APPLY_ACTION]: Точечная вставка / изменение / удаление узла.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Tuple, Dict, Any
|
||||
from core.connection import get_connection
|
||||
|
||||
logger = logging.getLogger("PROMPT_REPO")
|
||||
|
||||
|
||||
# ANCHOR[PROMPT_REPO_GET_ACTIVE]
|
||||
def repo_get_active_prompt(prompt_name: str = "main_agent") -> str:
|
||||
"""Собирает структурированный текст системного промпта из активных узлов."""
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT section_id, item_id, content
|
||||
FROM system_prompt_nodes
|
||||
WHERE prompt_name = ? AND is_active = 1
|
||||
ORDER BY section_id ASC, item_id ASC
|
||||
""", (prompt_name,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
return "Ты — ИИ-ассистент SCUD Orion AI."
|
||||
|
||||
lines = []
|
||||
current_section = None
|
||||
|
||||
for r in rows:
|
||||
sec_id = r["section_id"]
|
||||
itm_id = r["item_id"]
|
||||
content = r["content"]
|
||||
|
||||
if itm_id == 0:
|
||||
if current_section is not None:
|
||||
lines.append("")
|
||||
lines.append(f"{sec_id}. {content}")
|
||||
current_section = sec_id
|
||||
else:
|
||||
lines.append(f" {sec_id}.{itm_id}. {content}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ANCHOR[PROMPT_REPO_APPLY_ACTION]
|
||||
def repo_apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> None:
|
||||
"""Точечно применяет действие (ADD / UPDATE / DELETE) над узлом промпта."""
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
action_clean = (action or "").upper()
|
||||
if action_clean in ["ADD", "UPDATE", "EDIT"]:
|
||||
cursor.execute("""
|
||||
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
is_active = 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (prompt_name, section_id, item_id, content))
|
||||
elif action_clean == "DELETE":
|
||||
cursor.execute("""
|
||||
DELETE FROM system_prompt_nodes
|
||||
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
|
||||
""", (prompt_name, section_id, item_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def repo_save_full_prompt(prompt_text: str, prompt_name: str = "main_agent") -> None:
|
||||
"""Парсит и полностью перезаписывает все узлы промпта из сырого текста."""
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (prompt_name,))
|
||||
|
||||
current_sec = 1
|
||||
current_itm = 0
|
||||
|
||||
for raw_line in prompt_text.splitlines():
|
||||
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
|
||||
if not clean_line:
|
||||
continue
|
||||
|
||||
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
|
||||
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
|
||||
|
||||
if sub_match:
|
||||
current_sec = int(sub_match.group(1))
|
||||
current_itm = int(sub_match.group(2))
|
||||
content = sub_match.group(3).strip()
|
||||
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
|
||||
current_sec = int(sec_match.group(1))
|
||||
current_itm = 0
|
||||
content = sec_match.group(2).strip()
|
||||
else:
|
||||
current_itm += 1
|
||||
content = clean_line
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
""", (prompt_name, current_sec, current_itm, content))
|
||||
|
||||
conn.commit()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/prompts/service.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / prompts
|
||||
ROLE: Единый доменный сервис управления системным промптом.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Tuple, Dict, Any, List, Optional
|
||||
from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt
|
||||
from .diff_engine import build_prompt_diff
|
||||
|
||||
|
||||
def get_active_system_prompt() -> str:
|
||||
"""Получить текущий активный системный промпт."""
|
||||
return repo_get_active_prompt()
|
||||
|
||||
|
||||
def apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "") -> None:
|
||||
"""Применить точечное изменение к узлу промпта."""
|
||||
repo_apply_prompt_action(action, section_id, item_id, content)
|
||||
|
||||
|
||||
def save_full_prompt_draft(draft_text: str, prompt_name: str = "main_agent") -> None:
|
||||
"""Сохранить полный черновик промпта."""
|
||||
repo_save_full_prompt(draft_text, prompt_name=prompt_name)
|
||||
|
||||
|
||||
def create_prompt_preview(
|
||||
action: str,
|
||||
section_id: Optional[int] = None,
|
||||
item_id: Optional[int] = None,
|
||||
content: str = "",
|
||||
delete_nodes: Optional[List[Tuple[int, int]]] = None
|
||||
) -> Tuple[str, str, str]:
|
||||
"""
|
||||
Формирует черновик и diff.
|
||||
Возвращает (merged_draft, diff_html, baseline_prompt).
|
||||
"""
|
||||
baseline = repo_get_active_prompt()
|
||||
draft, diff_html = build_prompt_diff(
|
||||
action=action,
|
||||
section_id=section_id,
|
||||
item_id=item_id,
|
||||
content=content,
|
||||
delete_nodes=delete_nodes
|
||||
)
|
||||
return draft, diff_html, baseline
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/anomaly_detector.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / scud_etl
|
||||
ROLE: Детектирование истинных аномалий и конфликтов реестров.
|
||||
(Сотрудники из exceptions.json, удаленка и командировки исключены).
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger("SCUD_ANOMALY")
|
||||
|
||||
|
||||
def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Выявляет реальные аномалии:
|
||||
- Приход в офис во время отпуска или больничного листа.
|
||||
- Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа).
|
||||
"""
|
||||
anomalies = []
|
||||
if df_merged is None or df_merged.empty:
|
||||
return anomalies
|
||||
|
||||
for _, row in df_merged.iterrows():
|
||||
# Пропускаем системные исключения
|
||||
if row.get("is_excluded", False):
|
||||
continue
|
||||
|
||||
fio = row.get("fio_clean") or row.get("Сотрудник", "")
|
||||
start_day = str(row.get("Начало_дня", "")).strip()
|
||||
end_day = str(row.get("Конец_дня", "")).strip()
|
||||
reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip()
|
||||
reason_lower = reason.lower()
|
||||
|
||||
# Пропускаем технические метки
|
||||
if "исключен" in reason_lower or "овк" in reason_lower:
|
||||
continue
|
||||
|
||||
# ⭐️ Физический приход в офис: удаленка и командировки разрешены и НЕ являются аномалией
|
||||
if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan":
|
||||
if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
|
||||
anomalies.append({
|
||||
"fio": fio,
|
||||
"type": "PHYSICAL_PRESENCE_DURING_ABSENCE",
|
||||
"description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'."
|
||||
})
|
||||
|
||||
# Аномалия оборудования (есть выход без утреннего входа)
|
||||
if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]:
|
||||
anomalies.append({
|
||||
"fio": fio,
|
||||
"type": "SCUD_EQUIPMENT_ANOMALY",
|
||||
"description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})."
|
||||
})
|
||||
|
||||
return anomalies
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/merger.py
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
import pandas as pd
|
||||
|
||||
from services.knowledge.service import get_department_synonyms_dict
|
||||
from config import normalize_fio, load_exceptions
|
||||
|
||||
logger = logging.getLogger("SCUD_MERGER")
|
||||
|
||||
|
||||
def merge_scud_and_1c(
|
||||
df_scud: pd.DataFrame,
|
||||
df_staff_1c: pd.DataFrame,
|
||||
df_absences_1c: pd.DataFrame
|
||||
) -> pd.DataFrame:
|
||||
if (df_scud is None or df_scud.empty) and (df_staff_1c is None or df_staff_1c.empty):
|
||||
return pd.DataFrame(columns=[
|
||||
'Сотрудник', 'fio_clean', 'Подразделение', 'Должность',
|
||||
'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании',
|
||||
'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', 'is_excluded'
|
||||
])
|
||||
|
||||
synonyms = get_department_synonyms_dict()
|
||||
exceptions_cfg = load_exceptions()
|
||||
|
||||
df_res = df_scud.copy() if df_scud is not None and not df_scud.empty else df_staff_1c.copy()
|
||||
|
||||
if "Сотрудник" in df_res.columns:
|
||||
df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio)
|
||||
elif "ФИО" in df_res.columns:
|
||||
df_res["Сотрудник"] = df_res["ФИО"]
|
||||
df_res["fio_clean"] = df_res["ФИО"].apply(normalize_fio)
|
||||
elif "fio_clean" not in df_res.columns:
|
||||
df_res["fio_clean"] = ""
|
||||
|
||||
for col, default_val in [
|
||||
('Начало_дня', 'Нет входа'),
|
||||
('Первая_активность', '—'),
|
||||
('Конец_дня', 'Нет выхода'),
|
||||
('Находился_в_здании', '00:00'),
|
||||
('Пришел', False),
|
||||
('anomaly_flag', 'NONE')
|
||||
]:
|
||||
if col not in df_res.columns:
|
||||
df_res[col] = default_val
|
||||
|
||||
# 1. Приведение отделов к аббревиатурам
|
||||
reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()}
|
||||
direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()}
|
||||
all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"}
|
||||
|
||||
if "Подразделение" in df_res.columns:
|
||||
df_res["Подразделение"] = df_res["Подразделение"].apply(
|
||||
lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip())
|
||||
)
|
||||
|
||||
# 2. Привязка документов отсутствий из 1С ПЕРЕД разметкой исключений
|
||||
absences_map = {}
|
||||
if not df_absences_1c.empty:
|
||||
fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None)
|
||||
reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None)
|
||||
|
||||
if fio_col and reason_col:
|
||||
for _, row in df_absences_1c.iterrows():
|
||||
fio = normalize_fio(str(row[fio_col]))
|
||||
reason = str(row[reason_col]).strip()
|
||||
if reason and reason.lower() != "nan":
|
||||
absences_map[fio] = reason
|
||||
|
||||
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
|
||||
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
|
||||
|
||||
# 3. Разметка исключений
|
||||
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
|
||||
exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d]
|
||||
exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
|
||||
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
|
||||
whitelist_fios = [normalize_fio(f) for f in exceptions_cfg.get("include_fio", []) if f]
|
||||
|
||||
df_res["is_excluded"] = False
|
||||
for idx, row in df_res.iterrows():
|
||||
fio = row.get("fio_clean", "")
|
||||
has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() != ""
|
||||
|
||||
# ⭐️ Если сотрудник в белом списке ИЛИ у него есть официальное отсутствие — он НЕ исключается
|
||||
if fio in whitelist_fios or has_official_absence:
|
||||
df_res.at[idx, "is_excluded"] = False
|
||||
continue
|
||||
|
||||
dep = str(row.get("Подразделение", "")).upper()
|
||||
pos = str(row.get("Должность", "")).lower()
|
||||
|
||||
if fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw):
|
||||
df_res.at[idx, "is_excluded"] = True
|
||||
|
||||
# Чистое "Исключение" только для истинно исключенных без документов 1С
|
||||
mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == ""))
|
||||
df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение"
|
||||
df_res.loc[mask_exc, "причина отсутствия"] = "Исключение"
|
||||
|
||||
return df_res
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# 1. Строго физически пришедшие в офис
|
||||
came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа")
|
||||
working_in_office = active_df[came_to_office_mask]
|
||||
working_in_office_count = len(working_in_office)
|
||||
|
||||
# 2. Не пришедшие в офис
|
||||
df_not_came = active_df[~came_to_office_mask]
|
||||
|
||||
# 3. Из не пришедших — удаленщики (работают из дома)
|
||||
reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
|
||||
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
|
||||
remote_home = df_not_came[is_remote_mask]
|
||||
remote_home_count = len(remote_home)
|
||||
|
||||
# 4. Из оставшихся — официальные отсутствия (отпуск, больничный, командировка)
|
||||
df_remaining_absent = df_not_came[~is_remote_mask]
|
||||
has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \
|
||||
df_remaining_absent["причина отсутствия"].ne("") & \
|
||||
df_remaining_absent["причина отсутствия"].ne("nan") & \
|
||||
(~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение"))
|
||||
official_absent_count = len(df_remaining_absent[has_doc_mask])
|
||||
|
||||
# 5. Неизвестные
|
||||
unknown = df_remaining_absent[~has_doc_mask]
|
||||
unknown_count = len(unknown)
|
||||
|
||||
return {
|
||||
"total_staff": total_staff,
|
||||
"working_in_office_count": working_in_office_count,
|
||||
"remote_home_count": remote_home_count,
|
||||
"official_absent_count": official_absent_count,
|
||||
"unknown_count": unknown_count,
|
||||
"unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/pipeline.py
|
||||
ROLE: Выборка данных СКУД из SQLite (scud_logs) с жестким приоритетом Y-снапшотов.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import pandas as pd
|
||||
from core.connection import get_connection
|
||||
from services.data_loader import load_1c_data_smart
|
||||
|
||||
logger = logging.getLogger("SCUD_PIPELINE")
|
||||
|
||||
|
||||
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
|
||||
"""Извлекает срез СКУД. Для вчерашнего дня строго берет финальный вечерний срез Y."""
|
||||
with get_connection() as conn:
|
||||
df = pd.DataFrame()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if prefer_final_y:
|
||||
cursor.execute(
|
||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
|
||||
(date_str,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row and row[0]:
|
||||
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
|
||||
|
||||
if df.empty:
|
||||
cursor.execute(
|
||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
|
||||
(date_str,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row and row[0]:
|
||||
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
|
||||
|
||||
if not df.empty:
|
||||
rename_map = {
|
||||
'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник',
|
||||
'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня',
|
||||
'time_in_building': 'Находился_в_здании', 'is_present': 'Пришел'
|
||||
}
|
||||
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
|
||||
if 'fio_clean' not in df.columns and 'Сотрудник' in df.columns:
|
||||
df['fio_clean'] = df['Сотрудник'].astype(str).str.strip()
|
||||
if 'Пришел' in df.columns:
|
||||
df['Пришел'] = df['Пришел'].astype(bool)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
df_staff, df_absences = load_1c_data_smart(date_str, use_db=False)
|
||||
return (df_staff if df_staff is not None else pd.DataFrame(),
|
||||
df_absences if df_absences is not None else pd.DataFrame())
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/sql_queries.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / scud_etl
|
||||
ROLE: Хранилище сырых SQL-шаблонов для выгрузки из MS SQL Server (СКУД Орион Pro).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]: T-SQL запрос с расчетом первой активности и длительности.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]
|
||||
SCUD_EXPORT_QUERY_TEMPLATE = r"""
|
||||
DECLARE @InputDate DATE = '{target_date}';
|
||||
DECLARE @TargetDate DATE = @InputDate;
|
||||
|
||||
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
|
||||
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
|
||||
|
||||
WITH DailyLogs AS (
|
||||
SELECT
|
||||
log.HozOrgan AS EmployeeID,
|
||||
log.TimeVal,
|
||||
log.Event,
|
||||
log.Mode,
|
||||
CASE
|
||||
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
|
||||
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
|
||||
ELSE 'OTHER'
|
||||
END AS Direction,
|
||||
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
|
||||
FROM pLogData log WITH (NOLOCK)
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
|
||||
),
|
||||
Passages AS (
|
||||
SELECT
|
||||
EmployeeID,
|
||||
MIN(TimeVal) AS FirstRawEvent,
|
||||
MAX(TimeVal) AS LastRawEvent,
|
||||
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
|
||||
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
|
||||
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
|
||||
FROM DailyLogs
|
||||
GROUP BY EmployeeID
|
||||
)
|
||||
SELECT
|
||||
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
|
||||
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
|
||||
LTRIM(RTRIM(
|
||||
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
|
||||
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
|
||||
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
|
||||
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
|
||||
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
|
||||
)) AS [Сотрудник],
|
||||
ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность],
|
||||
ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№],
|
||||
CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата],
|
||||
ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня],
|
||||
CASE
|
||||
WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20))
|
||||
ELSE N'—'
|
||||
END AS [Первая_активность],
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
|
||||
THEN N'Нет выхода'
|
||||
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
|
||||
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
|
||||
ELSE N'Нет выхода'
|
||||
END AS [Конец_дня],
|
||||
CASE
|
||||
WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
|
||||
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
|
||||
END) / 60 AS VARCHAR), 2) + ':' +
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
|
||||
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
|
||||
END) % 60 AS VARCHAR), 2)
|
||||
ELSE N'00:00'
|
||||
END AS [Находился_в_здании],
|
||||
CASE
|
||||
WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал'
|
||||
ELSE N'Отсутствовал (Нет событий)'
|
||||
END AS [Статус]
|
||||
FROM pList p WITH (NOLOCK)
|
||||
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
|
||||
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
|
||||
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID
|
||||
WHERE
|
||||
ISNULL(p.StatusRecord, 0) = 0
|
||||
AND p.DateTimeInArchive IS NULL
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
|
||||
AND p.Name NOT LIKE N'бр.%'
|
||||
AND p.Name NOT LIKE N'Гость%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
|
||||
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
|
||||
ORDER BY p.Name ASC;
|
||||
"""
|
||||
@@ -28,6 +28,7 @@ LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
|
||||
|
||||
logger = logging.getLogger("scud_export")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
if not logger.handlers:
|
||||
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
|
||||
@@ -52,7 +53,7 @@ def log(message: str, level: str = "INFO"):
|
||||
logger.log(level_map.get(level, logging.INFO), message)
|
||||
|
||||
|
||||
SERVER_NAME = r"172.16.31.221\SQL"
|
||||
SERVER_NAME = r"172.16.200.147\SQL"
|
||||
DATABASE_NAME = "Orion-14.01.21-1"
|
||||
SQL_USER = "sa"
|
||||
SQL_PASSWORD = "123456"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/snapshots/service.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / snapshots
|
||||
ROLE: Бизнес-логика срезов СКУД (выборка, валидация Y-срезов, удаление).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов.
|
||||
- ANCHOR[SNAPSHOT_DELETE_SAFE]: Безопасное удаление дневных срезов с защитой итоговых.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from core.connection import get_connection
|
||||
from core.repositories.scud_repo import get_available_snapshots, delete_snapshot_by_id
|
||||
|
||||
|
||||
# ANCHOR[SNAPSHOT_GET_REGISTRY]
|
||||
def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Возвращает реестр снапшотов за дату или за все доступные дни."""
|
||||
clean_date = date_str.strip() if date_str else ""
|
||||
rows = get_available_snapshots(date_str=clean_date if clean_date else None)
|
||||
|
||||
snapshots = [
|
||||
{
|
||||
"snapshot_id": r[0],
|
||||
"log_date": r[1],
|
||||
"snapshot_time": r[2],
|
||||
"record_count": r[3],
|
||||
"is_final": str(r[0]).startswith("Y")
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"query_date": clean_date or "все",
|
||||
"snapshots_count": len(snapshots),
|
||||
"snapshots": snapshots
|
||||
}
|
||||
|
||||
|
||||
# ANCHOR[SNAPSHOT_DELETE_SAFE]
|
||||
def delete_snapshots_safely(snapshot_ids: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Удаляет выбранные дневные снапшоты.
|
||||
Итоговые вечерние срезы с префиксом 'Y' гарантированно защищены от удаления.
|
||||
"""
|
||||
if not snapshot_ids:
|
||||
return {"status": "error", "message": "Не указаны ID снапшотов для удаления."}
|
||||
|
||||
safe_ids = [str(s).strip() for s in snapshot_ids if s and not str(s).strip().startswith("Y")]
|
||||
|
||||
if not safe_ids:
|
||||
return {"status": "error", "message": "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."}
|
||||
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
placeholders = ",".join(["?"] * len(safe_ids))
|
||||
cursor.execute(f"DELETE FROM scud_logs WHERE snapshot_id IN ({placeholders})", safe_ids)
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"deleted_count": deleted_count,
|
||||
"deleted_ids": safe_ids,
|
||||
"message": f"Успешно удалено снапшотов: {len(safe_ids)} шт."
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/tasks/exporter.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / tasks
|
||||
ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
from config import OUTPUT_DIR
|
||||
from .repository import repo_get_tasks
|
||||
|
||||
logger = logging.getLogger("TASK_EXPORTER")
|
||||
|
||||
|
||||
# ANCHOR[TASK_EXPORT_MARKDOWN]
|
||||
def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/."""
|
||||
tasks = repo_get_tasks(user_id)
|
||||
if not tasks:
|
||||
return {"status": "error", "message": "Список задач пуст, экспорт отменен"}
|
||||
|
||||
# 1. Фильтрация задач по статусу
|
||||
if status_filter and status_filter.upper() != "ALL":
|
||||
tgt = status_filter.upper()
|
||||
if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]:
|
||||
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]]
|
||||
elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]:
|
||||
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]]
|
||||
elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]:
|
||||
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]]
|
||||
|
||||
if not tasks:
|
||||
return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"}
|
||||
|
||||
target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md"
|
||||
if not target_filename.endswith(".md"):
|
||||
target_filename = f"{target_filename}.md"
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 2. Группировка по модулям
|
||||
modules: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for t in tasks:
|
||||
mod = t.get("module") or "general"
|
||||
modules.setdefault(mod, []).append(t)
|
||||
|
||||
md_lines = [
|
||||
"# 🗺️ Дорожная карта задач проекта (ROADMAP)\n",
|
||||
f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n",
|
||||
"---\n"
|
||||
]
|
||||
|
||||
for mod_name, mod_tasks in sorted(modules.items()):
|
||||
md_lines.append(f"## Модуль `{mod_name}`\n")
|
||||
for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)):
|
||||
status = str(t.get("status", "BACKLOG")).upper()
|
||||
is_done = status in ["COMPLETED", "DONE"]
|
||||
is_progress = status in ["IN_PROGRESS", "PROGRESS"]
|
||||
|
||||
check_box = "[x]" if is_done else "[ ]"
|
||||
t_id = t.get("id")
|
||||
title = t.get("title", "Без названия")
|
||||
prio = t.get("priority", "MEDIUM")
|
||||
due = f" *(срок: {t['due_date']})*" if t.get("due_date") else ""
|
||||
status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "")
|
||||
|
||||
md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}")
|
||||
|
||||
md_lines.append("\n---\n")
|
||||
|
||||
content = "\n".join(md_lines)
|
||||
|
||||
# 3. Сохранение файла в изолированную сессионную папку
|
||||
tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export")
|
||||
os.makedirs(tool_dir, exist_ok=True)
|
||||
|
||||
session_token = uuid.uuid4().hex[:8]
|
||||
session_dir = os.path.join(tool_dir, session_token)
|
||||
os.makedirs(session_dir, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(session_dir, target_filename)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": target_filename,
|
||||
"filepath": filepath,
|
||||
"download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}",
|
||||
"tasks_count": len(tasks),
|
||||
"message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})."
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/tasks/repository.py
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from core.connection import get_connection
|
||||
|
||||
|
||||
def normalize_task_id(task_id_input: str) -> str:
|
||||
if not task_id_input:
|
||||
return ""
|
||||
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "")
|
||||
if clean_id.isdigit():
|
||||
num = int(clean_id)
|
||||
return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}"
|
||||
return f"TASK-{clean_id}"
|
||||
|
||||
|
||||
def 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()
|
||||
if status and status.upper() != "ALL":
|
||||
target_status = status.upper()
|
||||
if target_status in ["PROGRESS", "В РАБОТЕ"]:
|
||||
target_status = "IN_PROGRESS"
|
||||
elif target_status in ["DONE", "ГОТОВО"]:
|
||||
target_status = "COMPLETED"
|
||||
elif target_status in ["PLANNED", "ПЛАНЫ"]:
|
||||
target_status = "BACKLOG"
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id, task_id, module, title, priority, status, due_date, created_at
|
||||
FROM tasks
|
||||
WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED'))
|
||||
ORDER BY id DESC
|
||||
""", (user_id, target_status, target_status))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT id, task_id, module, title, priority, status, due_date, created_at
|
||||
FROM tasks
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
""", (user_id,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def repo_add_task(
|
||||
user_id: int,
|
||||
module: str,
|
||||
title: str,
|
||||
priority: str = "MEDIUM",
|
||||
due_date: Optional[str] = None,
|
||||
status: str = "BACKLOG"
|
||||
) -> Dict[str, Any]:
|
||||
target_status = status.upper() if status else "BACKLOG"
|
||||
if target_status in ["PROGRESS", "В РАБОТЕ"]:
|
||||
target_status = "IN_PROGRESS"
|
||||
elif target_status in ["DONE", "ГОТОВО"]:
|
||||
target_status = "COMPLETED"
|
||||
elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]:
|
||||
target_status = "BACKLOG"
|
||||
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT MAX(id) FROM tasks")
|
||||
max_id = cursor.fetchone()[0] or 0
|
||||
new_task_id = f"TASK-{(max_id + 1):02d}"
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id))
|
||||
conn.commit()
|
||||
return {"status": "success", "task_id": new_task_id, "id": max_id + 1}
|
||||
|
||||
|
||||
def repo_update_task(
|
||||
user_id: int,
|
||||
task_id: str,
|
||||
title: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
due_date: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
clean_num = re.sub(r'\D', '', str(task_id))
|
||||
formatted_id = normalize_task_id(task_id)
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if title is not None and title.strip():
|
||||
updates.append("title = ?")
|
||||
params.append(title.strip())
|
||||
|
||||
if priority is not None and priority.strip():
|
||||
updates.append("priority = ?")
|
||||
params.append(priority.strip().upper())
|
||||
|
||||
if status is not None and status.strip():
|
||||
target_status = status.strip().upper()
|
||||
if target_status in ["PROGRESS", "В РАБОТЕ"]:
|
||||
target_status = "IN_PROGRESS"
|
||||
elif target_status in ["DONE", "ГОТОВО"]:
|
||||
target_status = "COMPLETED"
|
||||
elif target_status in ["PLANNED", "ПЛАНЫ"]:
|
||||
target_status = "BACKLOG"
|
||||
updates.append("status = ?")
|
||||
params.append(target_status)
|
||||
|
||||
if due_date is not None:
|
||||
updates.append("due_date = ?")
|
||||
params.append(due_date.strip() if due_date.strip() else None)
|
||||
|
||||
if not updates:
|
||||
return {"status": "success", "message": "Нет данных для обновления"}
|
||||
|
||||
params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id])
|
||||
sql = f"""
|
||||
UPDATE tasks
|
||||
SET {', '.join(updates)}
|
||||
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
"""
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params)
|
||||
rows_affected = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
if rows_affected == 0:
|
||||
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
|
||||
|
||||
return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"}
|
||||
|
||||
|
||||
def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||
clean_num = re.sub(r'\D', '', str(task_id))
|
||||
formatted_id = normalize_task_id(task_id)
|
||||
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
DELETE FROM tasks
|
||||
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
""", (clean_num, formatted_id, f"%{task_id.strip()}", user_id))
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
if deleted == 0:
|
||||
return {"error": f"Задача {task_id} не найдена"}
|
||||
return {"status": "success", "message": f"Задача #{task_id} удалена"}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/tasks/service.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / tasks
|
||||
ROLE: Единый доменный сервис задач (бизнес-логика и диспетчер операций).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[TASK_SERVICE_DISPATCHER]: Маршрутизация действий ADD/UPDATE/DELETE/EXPORT.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from .repository import repo_get_tasks, repo_add_task, repo_update_task, repo_delete_task
|
||||
from .exporter import export_tasks_to_markdown
|
||||
|
||||
|
||||
def get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Получить список задач."""
|
||||
return repo_get_tasks(user_id, status)
|
||||
|
||||
|
||||
def add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None, status: str = "BACKLOG") -> Dict[str, Any]:
|
||||
"""Создать задачу."""
|
||||
res = repo_add_task(user_id, module, title, priority, due_date, status)
|
||||
return {"status": "success", "task_id": res["task_id"], "message": f"Задача #{res['id']} создана и добавлена в планы"}
|
||||
|
||||
|
||||
def update_task_details(user_id: int, task_id: str, title: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Обновить задачу."""
|
||||
return repo_update_task(user_id, task_id, title, priority, status, due_date)
|
||||
|
||||
|
||||
def delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||
"""Удалить задачу."""
|
||||
return repo_delete_task(user_id, task_id)
|
||||
|
||||
|
||||
# ANCHOR[TASK_SERVICE_DISPATCHER]
|
||||
def execute_task_action(
|
||||
user_id: int,
|
||||
action: str,
|
||||
task_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
priority: Optional[str] = "MEDIUM",
|
||||
status: Optional[str] = None,
|
||||
module: Optional[str] = "general",
|
||||
due_date: Optional[str] = None,
|
||||
filename: Optional[str] = "ROADMAP.md"
|
||||
) -> Dict[str, Any]:
|
||||
"""Консолидированный диспетчер операций над задачами."""
|
||||
act = (action or "").strip().upper()
|
||||
|
||||
if act == "ADD":
|
||||
if not title:
|
||||
return {"status": "error", "message": "Для создания задачи требуется указать title"}
|
||||
return add_task(user_id, module or "general", title, priority or "MEDIUM", due_date, status or "BACKLOG")
|
||||
|
||||
elif act == "UPDATE":
|
||||
if not task_id:
|
||||
return {"status": "error", "message": "Для обновления требуется указать task_id"}
|
||||
return update_task_details(user_id, str(task_id), title, priority, status, due_date)
|
||||
|
||||
elif act == "DELETE":
|
||||
if not task_id:
|
||||
return {"status": "error", "message": "Для удаления требуется указать task_id"}
|
||||
return delete_task(user_id, str(task_id))
|
||||
|
||||
elif act == "EXPORT":
|
||||
return export_tasks_to_markdown(user_id, filename=filename or "ROADMAP.md", status_filter=status)
|
||||
|
||||
return {"status": "error", "message": f"Неизвестное действие action='{action}'"}
|
||||
+31
-26
@@ -6,10 +6,6 @@ from services.knowledge_base import load_knowledge_base
|
||||
|
||||
|
||||
def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
|
||||
"""
|
||||
Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ).
|
||||
Сравнивает фамилию и полное имя с порогом сходства >= 0.75.
|
||||
"""
|
||||
if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty:
|
||||
return []
|
||||
|
||||
@@ -50,40 +46,46 @@ def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
|
||||
|
||||
|
||||
def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"):
|
||||
# 1. Загрузка Базы Знаний
|
||||
kb = load_knowledge_base()
|
||||
custom_rules = kb.get("rules", [])
|
||||
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
|
||||
|
||||
# 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff)
|
||||
total_staff = len(merged_df)
|
||||
is_working_mask = (merged_df['Пришел'] == True) | (
|
||||
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
|
||||
)
|
||||
present_cnt = len(merged_df[is_working_mask])
|
||||
explained_cnt = len(absent_explained)
|
||||
|
||||
# 1. Пришедшие в офис
|
||||
present_office_cnt = len(merged_df[merged_df['Пришел'] == True])
|
||||
|
||||
# 2. Не пришедшие удаленщики
|
||||
reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower()
|
||||
is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False)
|
||||
remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason])
|
||||
|
||||
# 3. Официально отсутствующие (без удаленки)
|
||||
explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))])
|
||||
|
||||
unexplained_cnt = len(absent_unexplained)
|
||||
anomalies_cnt = len(anomalies_list) if anomalies_list else 0
|
||||
|
||||
# 3. Точный расчет неточных совпадений через Python
|
||||
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
|
||||
|
||||
# 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД
|
||||
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 "Аномалий не обнаружено."
|
||||
|
||||
unexplained_list = []
|
||||
if not absent_unexplained.empty:
|
||||
for idx, r in absent_unexplained.iterrows():
|
||||
fio = r.get('Сотрудник', r.get('fio_clean', ''))
|
||||
|
||||
dept_scud = str(r.get('department_scud', '')).strip()
|
||||
dept_1c = str(r.get('Подразделение', '')).strip()
|
||||
dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c
|
||||
if not dept:
|
||||
dept = '—'
|
||||
|
||||
pos = r.get('Должность', '—')
|
||||
unexplained_list.append(f"{fio} — {dept}, {pos}")
|
||||
|
||||
# 5. Сборка промпта для Ollama
|
||||
prompt = f"""
|
||||
Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
|
||||
Сформируй итоговую сводку кадрового контроля на {date_str}.
|
||||
@@ -93,14 +95,15 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
|
||||
|
||||
🚨 ВХОДНЫЕ МЕТРИКИ:
|
||||
- Всего сотрудников: {total_staff}
|
||||
- Работают (офис / удаленка / командировки): {present_cnt}
|
||||
- Итого на работе (в офисе): {present_office_cnt}
|
||||
- В том числе на удаленной работе: {remote_home_cnt}
|
||||
- Официально отсутствуют: {explained_cnt}
|
||||
- Неизвестно (истинно неотмеченные): {unexplained_cnt}
|
||||
- Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
|
||||
|
||||
---
|
||||
🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
|
||||
{json.dumps(anomalies_list, ensure_ascii=False, indent=2)}
|
||||
{anomalies_text_block}
|
||||
|
||||
---
|
||||
⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт):
|
||||
@@ -111,32 +114,34 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
|
||||
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
|
||||
|
||||
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
|
||||
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
|
||||
2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
|
||||
3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций.
|
||||
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". Если аномалий нет, напиши: "Аномалий не обнаружено."
|
||||
2. В разделе "Подозрения на ошибки сопоставления ФИО" используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если он пуст, напиши: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
|
||||
3. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
|
||||
4. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы без перечисления конкретных ФИО.
|
||||
|
||||
СТРОГИЙ ШАБЛОН ОТВЕТА:
|
||||
|
||||
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
|
||||
|
||||
- Всего офисных сотрудников: **{total_staff}**
|
||||
- Работают (офис / удаленка / командировки): **{present_cnt}**
|
||||
- Итого на работе (в офисе): **{present_office_cnt}**
|
||||
- В том числе на удаленной работе: **{remote_home_cnt}**
|
||||
- Официально отсутствуют: **{explained_cnt}**
|
||||
- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
|
||||
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
|
||||
|
||||
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
|
||||
(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.")
|
||||
(Выведи нумерованный текстовый список аномалий или 'Аномалий не обнаружено.')
|
||||
|
||||
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
|
||||
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.")
|
||||
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива ошибок ФИО. Если пуст — 'Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.')
|
||||
|
||||
#### Неизвестные случаи: {unexplained_cnt}
|
||||
(Выведи нумерованный список всех {unexplained_cnt} человек)
|
||||
|
||||
### Точечные рекомендации:
|
||||
(2-3 системные рекомендации без указания ФИО сотрудников)
|
||||
(2-3 системные рекомендации)
|
||||
"""
|
||||
|
||||
sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке по предоставленному Markdown-шаблону. Категорически запрещено выводить JSON или китайские символы."
|
||||
sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке обычным форматированным текстом. Категорически запрещено выводить фигурные скобки JSON или технический код."
|
||||
return ask_ollama(prompt, system_prompt=sys_prompt)
|
||||
@@ -87,7 +87,7 @@ def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame:
|
||||
|
||||
try:
|
||||
conn_str = get_zup_connection_string()
|
||||
with pyodbc.connect(conn_str, timeout=5) as conn:
|
||||
with pyodbc.connect(conn_str, timeout=30) as conn:
|
||||
df = pd.read_sql(query, conn, params=[target_date])
|
||||
|
||||
if not df.empty:
|
||||
|
||||
Reference in New Issue
Block a user