docs: update CHANGELOG and ROADMAP with section 10 (svodka/otchet split, Y-23:59:59)
This commit is contained in:
@@ -1,32 +1,44 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/anomaly_detector.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / scud_etl
|
||||
ROLE: Детектирование истинных аномалий и конфликтов реестров.
|
||||
(Сотрудники из exceptions.json, удаленка и командировки исключены).
|
||||
ROLE: Детектирование аномалий СКУД, дубликатов пропусков и несоответствий с 1С.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
import pandas as pd
|
||||
from config import normalize_fio
|
||||
|
||||
logger = logging.getLogger("SCUD_ANOMALY")
|
||||
|
||||
|
||||
def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Выявляет реальные аномалии:
|
||||
- Приход в офис во время отпуска или больничного листа.
|
||||
- Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа).
|
||||
"""
|
||||
def detect_registry_anomalies(df_merged: pd.DataFrame, df_raw_scud: pd.DataFrame = None) -> List[Dict[str, Any]]:
|
||||
anomalies = []
|
||||
if df_merged is None or df_merged.empty:
|
||||
return anomalies
|
||||
|
||||
# 1. ⭐️ Поиск дубликатов учеток/пропусков в сыром СКУД
|
||||
if df_raw_scud is not None and not df_raw_scud.empty:
|
||||
df_raw = df_raw_scud.copy()
|
||||
if 'fio_clean' not in df_raw.columns:
|
||||
f_col = 'Сотрудник' if 'Сотрудник' in df_raw.columns else 'ФИО'
|
||||
df_raw['fio_clean'] = df_raw[f_col].apply(normalize_fio)
|
||||
|
||||
counts = df_raw['fio_clean'].value_counts()
|
||||
duplicate_fios = counts[counts > 1].index.tolist()
|
||||
|
||||
for dup_fio in duplicate_fios:
|
||||
sub = df_raw[df_raw['fio_clean'] == dup_fio]
|
||||
departments = ", ".join(sub['Подразделение'].astype(str).unique())
|
||||
anomalies.append({
|
||||
"fio": dup_fio,
|
||||
"type": "DUPLICATE_SCUD_CARD",
|
||||
"description": f"Сотрудник заведен в СКУД {len(sub)} раза (отделы: {departments}). События входа и выхода объединены автоматически."
|
||||
})
|
||||
|
||||
# 2. Поиск кадровых аномалий и сбоев оборудования
|
||||
for _, row in df_merged.iterrows():
|
||||
# Пропускаем системные исключения
|
||||
if row.get("is_excluded", False):
|
||||
continue
|
||||
|
||||
@@ -36,25 +48,24 @@ def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
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}'."
|
||||
"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})."
|
||||
"description": f"Зафиксирован выход ({end_day}) при отсутствии отметки утреннего входа."
|
||||
})
|
||||
|
||||
return anomalies
|
||||
+129
-14
@@ -1,19 +1,141 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/merger.py
|
||||
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, List
|
||||
import pandas as pd
|
||||
|
||||
from services.knowledge.service import get_department_synonyms_dict
|
||||
from config import normalize_fio, load_exceptions
|
||||
from core.connection import get_connection
|
||||
|
||||
logger = logging.getLogger("SCUD_MERGER")
|
||||
|
||||
|
||||
def load_identity_mappings() -> Dict[str, str]:
|
||||
"""Загружает подтвержденные сопоставления ФИО (СКУД -> 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(
|
||||
df_scud: pd.DataFrame,
|
||||
df_staff_1c: pd.DataFrame,
|
||||
@@ -29,7 +151,10 @@ def merge_scud_and_1c(
|
||||
synonyms = get_department_synonyms_dict()
|
||||
exceptions_cfg = load_exceptions()
|
||||
|
||||
df_res = df_scud.copy() if df_scud is not None and not df_scud.empty else df_staff_1c.copy()
|
||||
df_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()
|
||||
|
||||
if "Сотрудник" in df_res.columns:
|
||||
df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio)
|
||||
@@ -50,7 +175,6 @@ def merge_scud_and_1c(
|
||||
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, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"}
|
||||
@@ -60,9 +184,8 @@ def merge_scud_and_1c(
|
||||
lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip())
|
||||
)
|
||||
|
||||
# 2. Привязка документов отсутствий из 1С ПЕРЕД разметкой исключений
|
||||
absences_map = {}
|
||||
if not df_absences_1c.empty:
|
||||
if df_absences_1c is not None and not df_absences_1c.empty:
|
||||
fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None)
|
||||
reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None)
|
||||
|
||||
@@ -76,7 +199,6 @@ def merge_scud_and_1c(
|
||||
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]
|
||||
@@ -86,9 +208,8 @@ def merge_scud_and_1c(
|
||||
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() != ""
|
||||
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
|
||||
@@ -99,7 +220,6 @@ def merge_scud_and_1c(
|
||||
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, "причина отсутствия"] = "Исключение"
|
||||
@@ -111,21 +231,17 @@ 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("") & \
|
||||
@@ -133,7 +249,6 @@ def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
|
||||
(~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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user