refactor: step 3 - decompose ETL pipeline into modular services/scud_etl package

This commit is contained in:
2026-08-21 10:12:26 +03:00
parent 16928de5bd
commit e8a542eded
7 changed files with 486 additions and 523 deletions
+72
View File
@@ -0,0 +1,72 @@
"""
===============================================================================
FILE: services/scud_etl/anomaly_detector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Автоматическое выявление аномалий и конфликтов между 1С:ЗУП и СКУД.
AI-CONTEXT-ANCHORS:
- ANCHOR[ANOMALY_DETECTOR_CORE]: Проверка физического присутствия в отпуске и перемещений без входа.
===============================================================================
"""
import pandas as pd
from typing import List, Dict, Any, Optional
ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк']
# ANCHOR[ANOMALY_DETECTOR_CORE]
def detect_all_anomalies(
merged_df: pd.DataFrame,
static_reasons_dict: dict,
kb_rules: List[str],
scud_fios_set: Optional[set] = None
) -> List[Dict[str, Any]]:
"""Выявляет конфликты и аномалии между источниками СКУД и 1С."""
anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
for idx, row in merged_df.iterrows():
fio = row.get('Сотрудник', row.get('fio_clean', ''))
fio_clean = row.get('fio_clean', '')
is_present = row.get('Пришел', False)
is_exc = row.get('is_excluded', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
anom_flag = row.get('anomaly_flag', 'NONE')
is_fio_whitelisted = fio_clean.lower() in kb_rules_text
# 1. Присутствие при официальном отсутствии
if is_present and has_1c_reason:
is_allowed_trip = any(kw in reason_1c.lower() for kw in ALLOWED_WORK_TRIP_KEYWORDS)
if not is_allowed_trip and not is_fio_whitelisted:
anomalies.append({
"type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
"fio": fio,
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
})
if is_exc and not has_1c_reason:
continue
# 2. Перемещение внутри здания без отметки входа на КПП
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
anomalies.append({
"type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
"fio": fio,
"details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
})
# 3. Сотрудник в штате 1С, но карты/профиля в СКУД нет
if scud_fios_set is not None:
if fio_clean not in scud_fios_set and not has_1c_reason:
anomalies.append({
"type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
"fio": fio,
"details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но отсутствует в СКУД"
})
return anomalies