Files
scud_ai/services/scud_etl/merger.py
T

364 lines
18 KiB
Python

"""
===============================================================================
FILE: services/scud_etl/merger.py
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки.
Распределение исключений в общий рабочий пул при отсутствии справок.
===============================================================================
"""
import logging
from typing import Dict, Any, List
import pandas as pd
from services.knowledge.service import get_department_synonyms_dict
from config import normalize_fio, load_exceptions
from core.connection import get_connection
logger = logging.getLogger("SCUD_MERGER")
def load_identity_mappings() -> Dict[str, str]:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI',
status TEXT DEFAULT 'ACTIVE',
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
cursor.execute("""
SELECT scud_fio, zup_fio
FROM person_identity_mapping
WHERE status = 'ACTIVE'
""")
return {r[0]: r[1] for r in cursor.fetchall()}
def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
if df_scud is None or df_scud.empty:
return df_scud
df = df_scud.copy()
if 'fio_clean' not in df.columns:
fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО'
df['fio_clean'] = df[fio_col].apply(normalize_fio)
mapping_dict = load_identity_mappings()
if mapping_dict:
df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f))
aggregated_rows = []
for fio_clean, group in df.groupby('fio_clean'):
if len(group) == 1:
aggregated_rows.append(group.iloc[0].to_dict())
continue
base_row = group.sort_values(by='Пришел', ascending=False).iloc[0].to_dict()
valid_ins = [
str(t).strip() for t in group['Начало_дня']
if str(t).strip() not in ['Нет входа', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Начало_дня'] = min(valid_ins) if valid_ins else 'Нет входа'
valid_outs = [
str(t).strip() for t in group['Конец_дня']
if str(t).strip() not in ['Нет выхода', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Конец_дня'] = max(valid_outs) if valid_outs else 'Нет выхода'
valid_acts = [
str(t).strip() for t in group['Первая_активность']
if str(t).strip() not in ['—', '', 'nan', 'None', '00:00:00']
]
base_row['Первая_активность'] = min(valid_acts) if valid_acts else '—'
base_row['Пришел'] = any(group['Пришел'] == True) or (base_row['Начало_дня'] != 'Нет входа')
durations = [str(d) for d in group['Находился_в_здании'] if str(d) not in ['00:00', '', 'nan']]
if durations:
base_row['Находился_в_здании'] = max(durations)
aggregated_rows.append(base_row)
return pd.DataFrame(aggregated_rows)
def select_best_zup_position(df_staff_1c: pd.DataFrame, df_scud_agg: pd.DataFrame) -> pd.DataFrame:
if df_staff_1c is None or df_staff_1c.empty:
return pd.DataFrame()
df_staff = df_staff_1c.copy()
if 'fio_clean' not in df_staff.columns:
f_col = 'ФИО' if 'ФИО' in df_staff.columns else 'Сотрудник'
df_staff['fio_clean'] = df_staff[f_col].apply(normalize_fio)
scud_dept_map = {}
if df_scud_agg is not None and not df_scud_agg.empty:
for _, r in df_scud_agg.iterrows():
scud_dept_map[r.get('fio_clean', '')] = str(r.get('Подразделение', '')).strip().upper()
best_rows = []
for fio, group in df_staff.groupby('fio_clean'):
if len(group) == 1:
best_rows.append(group.iloc[0].to_dict())
continue
target_scud_dept = scud_dept_map.get(fio, "")
matched_row = None
if target_scud_dept:
for _, r in group.iterrows():
dept_1c = str(r.get('Подразделение', '')).strip().upper()
if dept_1c == target_scud_dept or target_scud_dept in dept_1c or dept_1c in target_scud_dept:
matched_row = r.to_dict()
break
if not matched_row:
matched_row = group.iloc[0].to_dict()
best_rows.append(matched_row)
return pd.DataFrame(best_rows)
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', 'not_hired_yet', 'no_scud_pass'
])
synonyms = get_department_synonyms_dict()
exceptions_cfg = load_exceptions()
df_scud_agg = aggregate_scud_by_person(df_scud)
df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg)
staff_fios = set(df_staff_agg['fio_clean'].dropna().tolist()) if df_staff_agg is not None and not df_staff_agg.empty else set()
scud_dict = df_scud_agg.set_index('fio_clean').to_dict('index') if df_scud_agg is not None and not df_scud_agg.empty else {}
merged_rows = []
# 1. Формируем строки по официальному штату 1С
if df_staff_agg is not None and not df_staff_agg.empty:
for _, s_row in df_staff_agg.iterrows():
fio_c = s_row.get('fio_clean', '')
r = dict(s_row)
if 'Сотрудник' not in r or pd.isna(r['Сотрудник']) or str(r['Сотрудник']).strip() == '':
r['Сотрудник'] = r.get('ФИО', fio_c)
if fio_c in scud_dict:
# Сотрудник есть в СКУД — берем короткую аббревиатуру отдела из СКУД
scud_data = scud_dict[fio_c]
dept_scud = str(scud_data.get('Подразделение', '')).strip()
if dept_scud and dept_scud.lower() not in ['nan', 'none', '—', 'без подразделения']:
r['Подразделение'] = dept_scud
r['Начало_дня'] = scud_data.get('Начало_дня', 'Нет входа')
r['Первая_активность'] = scud_data.get('Первая_активность', '—')
r['Конец_дня'] = scud_data.get('Конец_дня', 'Нет выхода')
r['Находился_в_здании'] = scud_data.get('Находился_в_здании', '00:00')
r['Пришел'] = bool(scud_data.get('Пришел', False))
r['anomaly_flag'] = scud_data.get('anomaly_flag', 'NONE')
r['no_scud_pass'] = False
r['not_hired_yet'] = False
else:
# Сотрудника нет в СКУД (Нет пропуска)
r['Начало_дня'] = 'Нет входа'
r['Первая_активность'] = '—'
r['Конец_дня'] = 'Нет выхода'
r['Находился_в_здании'] = '00:00'
r['Пришел'] = False
r['anomaly_flag'] = 'NONE'
r['no_scud_pass'] = True
r['not_hired_yet'] = False
merged_rows.append(r)
# 2. Сотрудники из СКУД, которых еще нет в 1С (Не приняты на работу)
if df_scud_agg is not None and not df_scud_agg.empty:
for _, scud_row in df_scud_agg.iterrows():
fio_c = scud_row.get('fio_clean', '')
if fio_c not in staff_fios:
r = dict(scud_row)
r['not_hired_yet'] = True
r['no_scud_pass'] = False
if 'Должность' not in r or pd.isna(r['Должность']):
r['Должность'] = '—'
merged_rows.append(r)
df_res = pd.DataFrame(merged_rows)
if "Сотрудник" not in df_res.columns and "ФИО" in df_res.columns:
df_res["Сотрудник"] = df_res["ФИО"]
for col, default_val in [
('Начало_дня', 'Нет входа'),
('Первая_активность', '—'),
('Конец_дня', 'Нет выхода'),
('Находился_в_здании', '00:00'),
('Пришел', False),
('anomaly_flag', 'NONE'),
('not_hired_yet', False),
('no_scud_pass', False)
]:
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,
"отдел внутреннего контроля": "ОВК",
"отдел вневедомственного контроля": "ОВК",
"отдел авторского надзора и технического аудита": "ОАН",
"отдел инженерных изысканий": "ОИЗ",
"правовое управление": "ПУ",
"макетная мастерская": "ММ",
"отдел автоматизации": "ОА",
"испытательная геотехническая лаборатория лабораторного центра": "ИГТЛЛ",
"отдел экономики, смет и организации строительства": "ОЭС",
"отдел электротехники, связи и пожарной автоматики": "ОЭСС",
"бетонная лаборатория": "БЛ",
"управление главных инженеров проектов №1": "УГИП №1",
"управление главных инженеров проектов №2": "УГИП №2",
"строительный отдел": "СО",
"гидротехническая экспедиция": "ГЭ",
"планово-экономический отдел": "ПЭО",
"конструкторский отдел": "КО",
"отдел тепловодоснабжения и канализации": "ОТВК",
"электротехнический отдел": "ЭТО"
}
if "Подразделение" in df_res.columns:
df_res["Подразделение"] = df_res["Подразделение"].apply(
lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) if pd.notna(d) else "—"
)
# Привязка кадровых документов 1С
absences_map = {}
if df_absences_1c is not None and 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() not in ["nan", "none"]:
absences_map[fio] = reason
manual_reasons_map = {}
try:
from services.manual_absences_repo import get_active_manual_absences_for_date
target_date_val = df_res.get('Дата', pd.Series()).iloc[0] if 'Дата' in df_res.columns and not df_res.empty else None
if target_date_val:
m_records = get_active_manual_absences_for_date(str(target_date_val))
for mr in m_records:
manual_reasons_map[mr['fio_clean']] = mr['reason']
except Exception:
pass
df_res["detailed_reason"] = df_res["fio_clean"].map(manual_reasons_map).fillna("")
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
# Исключения и белый список
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() not in ["", "nan", "None", "Исключение"]
if fio in whitelist_fios:
df_res.at[idx, "is_excluded"] = False
continue
dep = str(row.get("Подразделение", "")).upper()
pos = str(row.get("Должность", "")).lower()
is_match_exc = (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))
if is_match_exc:
df_res.at[idx, "is_excluded"] = not has_official_absence
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]:
# Создаем независимую копию среза штата с собственным непрерывным индексом
df_staff_only = df_merged[~df_merged.get('not_hired_yet', False)].copy().reset_index(drop=True)
total_staff = len(df_staff_only)
is_exc_staff = df_staff_only.get('is_excluded', False) == True
is_no_pass_staff = df_staff_only.get('no_scud_pass', False) == True
came_to_office_mask = (df_staff_only["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (~is_exc_staff)
exc_without_doc_mask = (is_exc_staff) & (
df_staff_only["Вид_отсутствия"].isna() |
df_staff_only["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"])
)
working_in_office_count = int((came_to_office_mask | exc_without_doc_mask).sum())
df_not_working = df_staff_only[~came_to_office_mask & ~exc_without_doc_mask].copy().reset_index(drop=True)
reason_series = df_not_working["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home_count = int(is_remote_mask.sum())
df_remaining_absent = df_not_working[~is_remote_mask].copy().reset_index(drop=True)
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 = int(has_doc_mask.sum())
# Неизвестно: среди тех, у кого нет официального документа и кто имеет пропуск
is_no_pass_remaining = df_remaining_absent.get('no_scud_pass', False) == True
unknown = df_remaining_absent[~has_doc_mask & ~is_no_pass_remaining]
unknown_count = len(unknown)
no_pass_count = int((is_no_pass_staff & ~is_exc_staff).sum())
is_not_hired_all = df_merged.get('not_hired_yet', False) == True
is_exc_all = df_merged.get('is_excluded', False) == True
not_hired_count = int((is_not_hired_all & ~is_exc_all).sum())
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,
"no_pass_count": no_pass_count,
"not_hired_count": not_hired_count,
"unknown_count": unknown_count,
"unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
}