""" =============================================================================== 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