""" =============================================================================== FILE: services/scud_etl/merger.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: services / scud_etl ROLE: Агрегация реестров СКУД и 1С, наложение исключений, нормализация кодов подразделений и расчет сходящегося баланса присутствия. =============================================================================== """ import logging from typing import Dict, Any, List import pandas as pd # Используем правильные имена функций из services/knowledge/service.py from services.knowledge.service import get_department_synonyms_dict, get_rules 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: """ Объединяет реестры СКУД и 1С:ЗУП: - Применяет исключения (уборщики, ОВК и др.). - Устанавливает короткие аббревиатуры подразделений (КО, ОАН, РУК и др.). - Привязывает кадровые документы отсутствий из 1С. """ # Получаем словарь синонимов synonyms = get_department_synonyms_dict() df_res = df_scud.copy() if "Сотрудник" in df_res.columns: df_res["fio_clean"] = df_res["Сотрудник"].astype(str).str.strip() # 1. Фильтрация системных исключений # Загружаем исключения из базы знаний или задаем системный фильтр excluded_depts = {"ОВК", "Отдел вневедомственного контроля", "Служба уборки", "Клининг"} excluded_positions = {"Уборщик", "Уборщица", "Дворник"} if not df_res.empty: if "Подразделение" in df_res.columns: df_res = df_res[~df_res["Подразделение"].astype(str).isin(excluded_depts)] if "Должность" in df_res.columns: df_res = df_res[~df_res["Должность"].astype(str).isin(excluded_positions)] # 2. Трансляция подразделений в короткие аббревиатуры СКУД if "Подразделение" in df_res.columns: df_res["Подразделение"] = df_res["Подразделение"].apply( lambda d: synonyms.get(d, d) ) # 3. Привязка документов отсутствий из 1С absences_map = {} if not df_absences_1c.empty and "Сотрудник" in df_absences_1c.columns and "Причина" in df_absences_1c.columns: for _, row in df_absences_1c.iterrows(): fio = str(row["Сотрудник"]).strip() reason = str(row["Причина"]).strip() absences_map[fio] = reason df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) return df_res def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: """ Расчет строго сходящегося баланса присутствия: A (Итого на работе) + B (Удаленная работа) + C (Официально отсутствуют) + D (Неизвестно) = N (Всего) """ total_staff = len(df_merged) # 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис) came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа") working_in_office = df_merged[came_to_office_mask] working_in_office_count = len(working_in_office) # 2. Все, кто сегодня НЕ пришел в офис not_came_mask = ~came_to_office_mask df_not_came = df_merged[not_came_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") official_absent = df_remaining_absent[has_doc_mask] official_absent_count = len(official_absent) # 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 [] }