71 lines
3.6 KiB
Python
71 lines
3.6 KiB
Python
"""
|
|
===============================================================================
|
|
FILE: services/scud_etl/anomaly_detector.py
|
|
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, 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
|
|
|
|
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 |