""" =============================================================================== FILE: services/excel_exporter.py ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter. Корректный расчет часов удаленщиков и исключение лишних списков. =============================================================================== """ import os import math import time import pandas as pd import xlsxwriter from datetime import datetime, timedelta from xlsxwriter.exceptions import FileCreateError from config import REPORTS_DIR MONTHS_RU_GENITIVE = { 1: "января", 2: "февраля", 3: "марта", 4: "апреля", 5: "мая", 6: "июня", 7: "июля", 8: "августа", 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" } MONTHS_RU_NOMINATIVE = { 1: "январь", 2: "февраль", 3: "март", 4: "апрель", 5: "май", 6: "июнь", 7: "июль", 8: "август", 9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь" } def format_date_ru(date_str): date_clean = str(date_str).replace('_', '.') try: dt = datetime.strptime(date_clean, "%d.%m.%Y") return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}" except Exception: return date_str def get_dated_reports_dir(date_str): date_clean = str(date_str).replace('_', '.') try: dt = datetime.strptime(date_clean, "%d.%m.%Y") year_str = str(dt.year) month_name = MONTHS_RU_NOMINATIVE[dt.month] except Exception: now = datetime.now() year_str = str(now.year) month_name = MONTHS_RU_NOMINATIVE[now.month] target_dir = os.path.join(REPORTS_DIR, year_str, month_name) os.makedirs(target_dir, exist_ok=True) return target_dir def safe_close_workbook(wb, output_path, target_dir, filename): try: wb.close() print(f"[✓] Успешно сохранен: {output_path}") return output_path except (FileCreateError, OSError, PermissionError): alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_path = os.path.join(target_dir, alt_filename) try: wb.filename = alt_path wb._store_workbook() print(f"[⚠️] Исходный файл открыт в Excel! Сохранено как: {alt_path}") return alt_path except Exception as e: print(f"[❌] Ошибка сохранения даже резервного файла: {e}") return output_path def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]: try: parts = time_in_str.strip().split(':') hh = int(parts[0]) mm = int(parts[1]) if len(parts) > 1 else 0 ss = int(parts[2]) if len(parts) > 2 else 0 dt_in = datetime(2000, 1, 1, hh, mm, ss) dt_out = dt_in + timedelta(hours=8, minutes=30) return dt_out.strftime("%H:%M:%S"), "08:30", "0:00" except Exception: return "17:00:00", "08:30", "0:00" def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): """ Расчет отклонения от нормы. Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение. """ reason_clean = str(reason).strip().lower() if pd.notna(reason) else "" is_remote = "удален" in reason_clean or "дистанцион" in reason_clean has_building_time = isinstance(time_in_building_str, str) and time_in_building_str not in ['00:00', '0', '', 'None', 'nan', 'NaN'] # Если есть уважительная причина (больничный, отпуск, командировка и т.д.) не удаленка if reason_clean != "" and not is_remote: return "0:00" # Если удаленщик работал исключительно из дома (00:00 в здании) if is_remote and not has_building_time: return "0:00" # Если сотрудника не было в здании и нет уважительной причины if not has_building_time: return f"-{norm_hours}:00" try: parts = time_in_building_str.strip().split(':') hh = int(parts[0]) mm = int(parts[1]) if len(parts) > 1 else 0 total_in_building_minutes = hh * 60 + mm if total_in_building_minutes == 0: return "0:00" if is_remote else f"-{norm_hours}:00" work_minutes = max(0, total_in_building_minutes - lunch_minutes) norm_minutes = norm_hours * 60 diff = work_minutes - norm_minutes if diff == 0: return "0:00" sign = "-" if diff < 0 else "" abs_diff = abs(diff) res_hh = abs_diff // 60 res_mm = abs_diff % 60 return f"{sign}{res_hh}:{res_mm:02d}" except Exception: return "0:00" if is_remote else f"-{norm_hours}:00" # ============================================================================= # 1. ЕЖЕДНЕВНАЯ СВОДКА НА СЕГОДНЯ # ============================================================================= 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) ws = wb.add_worksheet("Лист_1") ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False) def make_fmt(bg_color=None, bold=False, align="left", wrap=False): d = { 'font_name': 'Calibri', 'font_size': 11, 'bold': bold, 'align': align, 'valign': 'vcenter', 'border': 1, 'border_color': '#D3D3D3', 'text_wrap': wrap } if bg_color: d['bg_color'] = bg_color return wb.add_format(d) fmt_hdr_l = make_fmt(bg_color='#D9E1F2', bold=True, align="left") fmt_hdr_r = make_fmt(bg_color='#D9E1F2', bold=True, align="right") fmt_tot_l = make_fmt(bg_color='#F2F2F2', bold=True, align="left") fmt_tot_r = make_fmt(bg_color='#F2F2F2', bold=True, align="right") fmt_empty = make_fmt() 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) ws.set_row(2, 20) ws.write(2, 0, "По списку", fmt_tot_l) ws.write(2, 1, len(merged_df), fmt_tot_r) current_row = 3 is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False is_exc = merged_df.get('is_excluded', False) == True # 1. Неизвестно (Раскрыто по умолчанию) unexplained = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (~is_no_pass) & (~is_exc) ] fmt_unexp_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left") fmt_unexp_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right") fmt_unexp_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left") fmt_unexp_rr = make_fmt(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, fio, fmt_unexp_rl) ws.write(current_row, 1, "", fmt_unexp_rr) current_row += 1 # 2. Нет пропуска (Раскрыто по умолчанию) no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() fmt_np_hl = make_fmt(bg_color='#E1F5FE', bold=True, align="left") fmt_np_hr = make_fmt(bg_color='#E1F5FE', bold=True, align="right") fmt_np_rl = make_fmt(bg_color='#E1F5FE', bold=False, align="left") fmt_np_rr = make_fmt(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, fio, fmt_np_rl) ws.write(current_row, 1, "", fmt_np_rr) current_row += 1 # 3. Официальные отсутствия (Сотрудники из исключений при наличии документа 1С попадают сюда) reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) absent_only = merged_df[ (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 = make_fmt(bg_color=hex_c, bold=True, align="left") fmt_cat_hr = make_fmt(bg_color=hex_c, bold=True, align="right") fmt_cat_rl = make_fmt(bg_color=hex_c, bold=False, align="left") fmt_cat_rr = make_fmt(bg_color=hex_c, bold=False, align="right") ws.set_row(current_row, 20) ws.write(current_row, 0, cat_name, fmt_cat_hl) ws.write(current_row, 1, len(group), fmt_cat_hr) current_row += 1 for fio in sorted(group['Сотрудник'].dropna().unique()): ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) ws.write(current_row, 0, fio, fmt_cat_rl) ws.write(current_row, 1, "", fmt_cat_rr) current_row += 1 # 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок) exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))] present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] total_present_count = len(present_scud) + len(exc_without_doc) fmt_pres_hl = make_fmt(bg_color='#E2EFDA', bold=True, align="left") fmt_pres_hr = make_fmt(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 # 5. Удаленная работа (Свернуто) remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] fmt_rem_hl = make_fmt(bg_color='#E8F8F5', bold=True, align="left") fmt_rem_hr = make_fmt(bg_color='#E8F8F5', bold=True, align="right") fmt_rem_rl = make_fmt(bg_color='#E8F8F5', bold=False, align="left") fmt_rem_rr = make_fmt(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, fio, fmt_rem_rl) ws.write(current_row, 1, "", fmt_rem_rr) current_row += 1 # 6. Аномалии СКУД и 1С (Свернуто) anomalies = merged_df[ (~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 = make_fmt(bg_color='#FCE4D6', bold=True, align="left") fmt_anom_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right") fmt_anom_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left") fmt_anom_rr = make_fmt(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('Сотрудник', '') reason = row.get('Вид_отсутствия', '') anom_flag = row.get('anomaly_flag', 'NONE') if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': first_act = row.get('Первая_активность', '—') reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})" else: reason_text = f"В 1С: {reason}" lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1 row_h = max(lines_count * 18, 20) ws.set_row(current_row, row_h, None, {'level': 1, 'hidden': True, 'collapsed': True}) ws.write(current_row, 0, fio, fmt_anom_rl) ws.write(current_row, 1, 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) # ============================================================================= # 2. ДЕТАЛЬНЫЙ СУТОЧНЫЙ ОТЧЕТ ЗА ВЧЕРА # ============================================================================= def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): date_clean = str(date_str).replace('_', '.') if not filename: filename = f"{format_date_ru(date_clean)} отчет.xlsx" if merged_df is not None and not merged_df.empty: df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() else: df_export = pd.DataFrame() target_dir = get_dated_reports_dir(date_clean) output_path = os.path.join(target_dir, filename) wb = xlsxwriter.Workbook(output_path) ws = wb.add_worksheet("Детальный_отчет") def make_fmt(bg_color=None, bold=False, align="left", wrap=False): d = { 'font_name': 'Arial', 'font_size': 10, 'bold': bold, 'align': align, 'valign': 'vcenter', 'border': 1, 'border_color': '#D3D3D3', 'text_wrap': wrap } if bg_color: d['bg_color'] = bg_color return wb.add_format(d) fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True}) ws.write(1, 1, "Дата:", fmt_date_lbl) ws.write(1, 3, date_clean, fmt_date_lbl) headers = [ "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "находился в здании", "причина отсутствия", "норма", "отклонение от нормы" ] fmt_hdr = make_fmt(bg_color='#D9E1F2', bold=True, align="center", wrap=True) ws.set_row(3, 26) for col_idx, h_text in enumerate(headers): ws.write(3, col_idx, h_text, fmt_hdr) start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня' end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня' hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании' chars_per_line_h = 24 for idx, row in df_export.reset_index(drop=True).iterrows(): row_num = 4 + idx is_present = row.get('Пришел', False) absence_reason = row.get('Вид_отсутствия', '') has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' in_val = str(row.get(start_col, 'Нет входа')).strip() out_val = str(row.get(end_col, 'Нет выхода')).strip() in_building_str = str(row.get(hours_col, '00:00')) first_act_val = str(row.get('Первая_активность', '—')).strip() has_first_act = first_act_val not in ['—', '', 'None', 'nan'] if in_val not in ['Нет входа', '—', '', 'nan', 'None'] and out_val in ['Нет выхода', '—', '', 'nan', 'None'] and not has_reason: out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val) else: deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) row_color = None if is_present and has_reason: row_color = '#E2EFDA' elif not is_present and has_reason: row_color = '#FFF2CC' elif not is_present and not has_reason and not has_first_act: row_color = '#FCE4D6' val_h_str = str(absence_reason) if has_reason else "" lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1 ws.set_row(row_num, max(lines_count * 18, 20)) values = [ (idx + 1, 'center', False), (row.get('Сотрудник', ''), 'left', False), (dept_scud_val, 'center', False), (in_val, 'center', False), (first_act_val, 'center', False), (out_val, 'center', False), (in_building_str, 'center', False), (absence_reason if has_reason else '', 'left', True), (8, 'center', False), (deviation_val, 'center', False) ] for col_idx, (val, align_type, is_wrap) in enumerate(values): fmt = make_fmt(bg_color=row_color, align=align_type, wrap=is_wrap) ws.write(row_num, col_idx, val, fmt) col_widths = { 0: 4, 1: 33, 2: 13, 3: 11, 4: 11, 5: 11, 6: 12, 7: 24, 8: 6, 9: 11 } for col_idx, width in col_widths.items(): ws.set_column(col_idx, col_idx, width) safe_close_workbook(wb, output_path, target_dir, filename) # ============================================================================= # 3. СЫРОЙ СКУД # ============================================================================= def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): output_path = os.path.join(REPORTS_DIR, filename) target_dir = os.path.dirname(output_path) wb = xlsxwriter.Workbook(output_path) ws = wb.add_worksheet("Сырые_данные") fmt_hdr = wb.add_format({ 'font_name': 'Calibri', 'font_size': 11, 'bold': True, 'bg_color': '#D9E1F2', 'border': 1, 'border_color': '#D3D3D3', 'align': 'center', 'valign': 'vcenter' }) fmt_cell = wb.add_format({ 'font_name': 'Calibri', 'font_size': 11, 'border': 1, 'border_color': '#D3D3D3', 'valign': 'vcenter', 'align': 'left' }) headers = list(df_scud.columns) ws.set_row(3, 28) for col_idx, header in enumerate(headers): ws.write(0, col_idx, str(header), fmt_hdr) col_widths = [len(str(h)) for h in headers] for row_idx, row_values in enumerate(df_scud.values, start=1): ws.set_row(row_idx, 19) for col_idx, val in enumerate(row_values): if pd.isna(val) or val is None: val_str = "" elif isinstance(val, bool): val_str = "Да" if val else "Нет" else: val_str = str(val) ws.write(row_idx, col_idx, val_str, fmt_cell) if len(val_str) > col_widths[col_idx]: col_widths[col_idx] = len(val_str) for col_idx, width in enumerate(col_widths): ws.set_column(col_idx, col_idx, min(max(width + 3, 10), 45)) safe_close_workbook(wb, output_path, target_dir, filename)