feat(reports): add simplified report builder, fix nan/inf crash and separate unhired/no-pass staff
This commit is contained in:
+121
-33
@@ -138,7 +138,8 @@ def merge_scud_and_1c(
|
||||
return pd.DataFrame(columns=[
|
||||
'Сотрудник', 'fio_clean', 'Подразделение', 'Должность',
|
||||
'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании',
|
||||
'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', 'is_excluded'
|
||||
'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия',
|
||||
'is_excluded', 'not_hired_yet', 'no_scud_pass'
|
||||
])
|
||||
|
||||
synonyms = get_department_synonyms_dict()
|
||||
@@ -147,15 +148,63 @@ def merge_scud_and_1c(
|
||||
df_scud_agg = aggregate_scud_by_person(df_scud)
|
||||
df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg)
|
||||
|
||||
df_res = df_scud_agg.copy() if df_scud_agg is not None and not df_scud_agg.empty else df_staff_agg.copy()
|
||||
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 {}
|
||||
|
||||
if "Сотрудник" in df_res.columns:
|
||||
df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio)
|
||||
elif "ФИО" in df_res.columns:
|
||||
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["ФИО"]
|
||||
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 [
|
||||
('Начало_дня', 'Нет входа'),
|
||||
@@ -163,20 +212,46 @@ def merge_scud_and_1c(
|
||||
('Конец_дня', 'Нет выхода'),
|
||||
('Находился_в_здании', '00:00'),
|
||||
('Пришел', False),
|
||||
('anomaly_flag', 'NONE')
|
||||
('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, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"}
|
||||
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())
|
||||
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)
|
||||
@@ -186,13 +261,12 @@ def merge_scud_and_1c(
|
||||
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":
|
||||
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
|
||||
# date_clean берется из даты контекста либо из текущих суток
|
||||
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))
|
||||
@@ -205,6 +279,7 @@ def merge_scud_and_1c(
|
||||
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]
|
||||
@@ -226,10 +301,7 @@ def merge_scud_and_1c(
|
||||
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:
|
||||
if has_official_absence:
|
||||
df_res.at[idx, "is_excluded"] = False
|
||||
else:
|
||||
df_res.at[idx, "is_excluded"] = True
|
||||
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, "Вид_отсутствия"] = "Исключение"
|
||||
@@ -239,38 +311,54 @@ def merge_scud_and_1c(
|
||||
|
||||
|
||||
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
|
||||
total_staff = len(df_merged)
|
||||
# Создаем независимую копию среза штата с собственным непрерывным индексом
|
||||
df_staff_only = df_merged[~df_merged.get('not_hired_yet', False)].copy().reset_index(drop=True)
|
||||
total_staff = len(df_staff_only)
|
||||
|
||||
came_to_office_mask = (df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (df_merged.get("is_excluded", False) == False)
|
||||
exc_without_doc_mask = (df_merged.get("is_excluded", False) == True) & (
|
||||
df_merged["Вид_отсутствия"].isna() |
|
||||
df_merged["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"])
|
||||
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 = len(df_merged[came_to_office_mask | exc_without_doc_mask])
|
||||
working_in_office_count = int((came_to_office_mask | exc_without_doc_mask).sum())
|
||||
|
||||
df_not_working = df_merged[~came_to_office_mask & ~exc_without_doc_mask]
|
||||
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 = df_not_working[is_remote_mask]
|
||||
remote_home_count = len(remote_home)
|
||||
remote_home_count = int(is_remote_mask.sum())
|
||||
|
||||
df_remaining_absent = df_not_working[~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])
|
||||
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())
|
||||
|
||||
unknown = df_remaining_absent[~has_doc_mask]
|
||||
# Неизвестно: среди тех, у кого нет официального документа и кто имеет пропуск
|
||||
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 []
|
||||
}
|
||||
Reference in New Issue
Block a user