""" =============================================================================== FILE: services/reports/svodka_builder.py ROLE: Генератор книги Ежедневной сводки (иерархические группировки XlsxWriter). =============================================================================== """ import os import math import pandas as pd import xlsxwriter from services.reports.styles import get_dated_reports_dir, format_date_ru, safe_close_workbook, create_xlsx_format def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): date_clean = str(date_str).replace('_', '.') if not filename: filename = f"{format_date_ru(date_clean)} сводка.xlsx" target_dir = get_dated_reports_dir(date_clean) output_path = os.path.join(target_dir, filename) wb = xlsxwriter.Workbook(output_path, {'nan_inf_to_errors': True}) ws = wb.add_worksheet("Лист_1") ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False) fmt_hdr_l = create_xlsx_format(wb, bg_color='#D9E1F2', bold=True, align="left") fmt_hdr_r = create_xlsx_format(wb, bg_color='#D9E1F2', bold=True, align="right") fmt_tot_l = create_xlsx_format(wb, bg_color='#F2F2F2', bold=True, align="left") fmt_tot_r = create_xlsx_format(wb, bg_color='#F2F2F2', bold=True, align="right") fmt_empty = create_xlsx_format(wb) ws.set_row(0, 20) ws.write(0, 0, "Сводка на", fmt_hdr_l) ws.write(0, 1, date_clean, fmt_hdr_r) ws.set_row(1, 20) ws.write(1, 0, "", fmt_empty) ws.write(1, 1, "", fmt_empty) is_not_hired = merged_df.get('not_hired_yet', False) == True is_no_pass = merged_df.get('no_scud_pass', False) == True is_exc = merged_df.get('is_excluded', False) == True # "По списку" строго по официальному штату 1С staff_total = len(merged_df[~is_not_hired]) ws.set_row(2, 20) ws.write(2, 0, "По списку", fmt_tot_l) ws.write(2, 1, staff_total, fmt_tot_r) current_row = 3 # 1. Неизвестно unexplained = merged_df[ (~is_not_hired) & (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'None']))) & (~is_no_pass) & (~is_exc) ] fmt_unexp_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left") fmt_unexp_hr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="right") fmt_unexp_rl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left") fmt_unexp_rr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="right") ws.set_row(current_row, 20) ws.write(current_row, 0, "неизвестно", fmt_unexp_hl) ws.write(current_row, 1, len(unexplained), fmt_unexp_hr) current_row += 1 for fio in sorted(unexplained['Сотрудник'].dropna().unique()): ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) ws.write(current_row, 0, str(fio), fmt_unexp_rl) ws.write(current_row, 1, "", fmt_unexp_rr) current_row += 1 # 2. Нет пропуска (в штате 1С есть, но карты СКУД нет) no_pass_df = merged_df[is_no_pass & (~is_exc) & (~is_not_hired)] fmt_np_hl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="left") fmt_np_hr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="right") fmt_np_rl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="left") fmt_np_rr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="right") ws.set_row(current_row, 20) ws.write(current_row, 0, "Нет в СКУД", fmt_np_hl) ws.write(current_row, 1, len(no_pass_df), fmt_np_hr) current_row += 1 if not no_pass_df.empty: for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) ws.write(current_row, 0, str(fio), fmt_np_rl) ws.write(current_row, 1, "", fmt_np_rr) current_row += 1 # 3. Не приняты на работу (в СКУД есть, но в 1С приказа еще нет) not_hired_df = merged_df[is_not_hired & (~is_exc)] fmt_nh_hl = create_xlsx_format(wb, bg_color='#FFF0F5', bold=True, align="left") fmt_nh_hr = create_xlsx_format(wb, bg_color='#FFF0F5', bold=True, align="right") fmt_nh_rl = create_xlsx_format(wb, bg_color='#FFF0F5', bold=False, align="left") fmt_nh_rr = create_xlsx_format(wb, bg_color='#FFF0F5', bold=False, align="right") ws.set_row(current_row, 20) ws.write(current_row, 0, "Нет в ЗУП", fmt_nh_hl) ws.write(current_row, 1, len(not_hired_df), fmt_nh_hr) current_row += 1 if not not_hired_df.empty: for fio in sorted(not_hired_df['Сотрудник'].dropna().unique()): ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) ws.write(current_row, 0, str(fio), fmt_nh_rl) ws.write(current_row, 1, "", fmt_nh_rr) current_row += 1 # 4. Официальные отсутствия reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) absent_only = merged_df[ (~is_not_hired) & (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & (~is_remote_reason) ] absent_groups = absent_only.groupby('Вид_отсутствия') pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF'] for idx_cat, (cat_name, group) in enumerate(absent_groups): hex_c = pastels[idx_cat % len(pastels)] fmt_cat_hl = create_xlsx_format(wb, bg_color=hex_c, bold=True, align="left") fmt_cat_hr = create_xlsx_format(wb, bg_color=hex_c, bold=True, align="right") fmt_cat_rl = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="left") fmt_cat_rr = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="right") ws.set_row(current_row, 20) ws.write(current_row, 0, str(cat_name), fmt_cat_hl) ws.write(current_row, 1, len(group), fmt_cat_hr) current_row += 1 is_other_category = (str(cat_name).strip().lower() == "иное") for _, row in group.sort_values(by='Сотрудник').iterrows(): fio = row.get('Сотрудник', '') detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else "" if pd.isna(detail_val): detail_val = "" ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) ws.write(current_row, 0, str(fio), fmt_cat_rl) ws.write(current_row, 1, str(detail_val), fmt_cat_rr) current_row += 1 # 5. Итого на работе (только принятые) exc_without_doc = merged_df[(~is_not_hired) & is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))] present_scud = merged_df[(~is_not_hired) & (merged_df['Пришел'] == True) & (~is_exc)] total_present_count = len(present_scud) + len(exc_without_doc) fmt_pres_hl = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="left") fmt_pres_hr = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="right") ws.set_row(current_row, 20) ws.write(current_row, 0, "Итого на работе", fmt_pres_hl) ws.write(current_row, 1, total_present_count, fmt_pres_hr) current_row += 1 # 6. Удаленная работа remote_home = merged_df[(~is_not_hired) & (merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] fmt_rem_hl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="left") fmt_rem_hr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="right") fmt_rem_rl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="left") fmt_rem_rr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="right") ws.set_row(current_row, 20) ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl) ws.write(current_row, 1, len(remote_home), fmt_rem_hr) current_row += 1 if not remote_home.empty: for fio in sorted(remote_home['Сотрудник'].dropna().unique()): ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) ws.write(current_row, 0, str(fio), fmt_rem_rl) ws.write(current_row, 1, "", fmt_rem_rr) current_row += 1 # 7. Аномалии СКУД и 1С anomalies = merged_df[ (~is_not_hired) & (~is_exc) & ( ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & (~is_remote_reason) & (~reason_clean.str.contains('командировк', na=False))) | (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') ) ] fmt_anom_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left") fmt_anom_hr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="right") fmt_anom_rl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left") fmt_anom_rr = create_xlsx_format(wb, bg_color='#FCE4D6', bold=False, align="left", wrap=True) ws.set_row(current_row, 20) ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl) ws.write(current_row, 1, len(anomalies), fmt_anom_hr) current_row += 1 chars_per_line_b = 30 if not anomalies.empty: for _, row in anomalies.iterrows(): fio = row.get('Сотрудник', '') anom_flag = row.get('anomaly_flag', 'NONE') reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {row.get('Первая_активность', '—')})" if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY' else f"В 1С: {row.get('Вид_отсутствия', '')}" lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1 ws.set_row(current_row, max(lines_count * 18, 20), None, {'level': 1, 'hidden': True, 'collapsed': True}) ws.write(current_row, 0, str(fio), fmt_anom_rl) ws.write(current_row, 1, str(reason_text), fmt_anom_rr) current_row += 1 ws.set_column(0, 0, 45) ws.set_column(1, 1, 38) safe_close_workbook(wb, output_path, target_dir, filename)