""" =============================================================================== 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 [] }