147 lines
7.4 KiB
Python
147 lines
7.4 KiB
Python
"""
|
|
===============================================================================
|
|
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 []
|
|
} |