import math import os import time import openpyxl import pandas as pd from datetime import datetime from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils import get_column_letter from config import OUTPUT_DIR # Словарь месяцев для текстового формата в названиях файлов MONTHS_RU = { 1: "января", 2: "февраля", 3: "марта", 4: "апреля", 5: "мая", 6: "июня", 7: "июля", 8: "августа", 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" } def format_date_ru(date_str): """Преобразует дату формата '27.07.2026' в '27 июля 2026'""" try: dt = datetime.strptime(date_str, "%d.%m.%Y") return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}" except Exception: return date_str # Границы ячеек THIN_SIDE = Side(border_style="thin", color="D3D3D3") THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE) # Палитра заливки для Сводки FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска" CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"] # Палитра для Детального отчета YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске) LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска" def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): """Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин""" if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "": return "0:00" if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']: 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 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 f"-{norm_hours}:00" def apply_borders_to_cell(cell, border=THIN_BORDER): cell.border = border def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False): cell_a = ws.cell(row=r_num, column=1) cell_b = ws.cell(row=r_num, column=2) if fill_obj: cell_a.fill = fill_obj cell_b.fill = fill_obj apply_borders_to_cell(cell_a) apply_borders_to_cell(cell_b) if is_bold and bold_font: cell_a.font = bold_font cell_b.font = bold_font if align_b: cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b) # --- 1. СВОДКА НА СЕГОДНЯ --- def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): if not filename: filename = f"{format_date_ru(date_str)} сводка.xlsx" output_path = os.path.join(OUTPUT_DIR, filename) wb = Workbook() ws = wb.active ws.title = "Лист_1" ws.sheet_properties.outlinePr.summaryBelow = False ws.sheet_properties.outlinePr.summaryRight = False ws.sheet_properties.outlinePr.showOutlineSymbols = True ws.sheet_view.showOutlineSymbols = True bold_font = Font(name="Calibri", size=11, bold=True) # 1. Шапка ws.cell(row=1, column=1, value="Сводка на") ws.cell(row=1, column=2, value=date_str) format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) apply_borders_to_cell(ws.cell(row=2, column=1)) apply_borders_to_cell(ws.cell(row=2, column=2)) # 2. По списку ws.cell(row=3, column=1, value="По списку") ws.cell(row=3, column=2, value=len(merged_df)) format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font) current_row = 4 # Подготовка флагов для точной фильтрации 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 # 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана") unexplained = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (~is_no_pass) & (~is_exc) ] ws.cell(row=current_row, column=1, value="неизвестно") ws.cell(row=current_row, column=2, value=len(unexplained)) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) current_row += 1 for fio in sorted(unexplained['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = False current_row += 1 # 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок) no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() ws.cell(row=current_row, column=1, value="Нет пропуска") ws.cell(row=current_row, column=2, value=len(no_pass_df)) format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) current_row += 1 if not no_pass_df.empty: for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = False current_row += 1 # 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) absent_only = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) ] absent_groups = absent_only.groupby('Вид_отсутствия') for idx_cat, (cat_name, group) in enumerate(absent_groups): hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)] cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid") ws.cell(row=current_row, column=1, value=cat_name) ws.cell(row=current_row, column=2, value=len(group)) format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) current_row += 1 for fio in sorted(group['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, cat_fill, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 # 6. ИТОГО НА РАБОТЕ (Свернут hidden=True) is_working_mask = (merged_df['Пришел'] == True) | ( merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) ) present = merged_df[is_working_mask] ws.cell(row=current_row, column=1, value="Итого на работе") ws.cell(row=current_row, column=2, value=len(present)) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font) current_row += 1 if not present.empty: for fio in sorted(present['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 # 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ) anomalies = merged_df[ (~is_exc) & ( ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) | (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') ) ] ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") ws.cell(row=current_row, column=2, value=len(anomalies)) format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) 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}" cell_a = ws.cell(row=current_row, column=1, value=f"{fio}") cell_b = ws.cell(row=current_row, column=2, value=reason_text) cell_a.fill = FILL_ANOMALY cell_b.fill = FILL_ANOMALY apply_borders_to_cell(cell_a) apply_borders_to_cell(cell_b) cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) cell_a.alignment = Alignment(horizontal="left", vertical="center") if len(reason_text) > chars_per_line_b: lines_count = math.ceil(len(reason_text) / chars_per_line_b) ws.row_dimensions[current_row].height = max(lines_count * 18, 22) else: ws.row_dimensions[current_row].height = 20 ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 ws.column_dimensions['A'].width = 45.0 ws.column_dimensions['B'].width = 38.0 try: wb.save(output_path) print(f"[✓] Ежедневная сводка сохранена: {output_path}") except PermissionError: alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_path = os.path.join(OUTPUT_DIR, alt_filename) wb.save(alt_path) print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") # --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): if not filename: filename = f"{format_date_ru(date_str)} отчет.xlsx" output_path = os.path.join(OUTPUT_DIR, filename) wb = Workbook() ws = wb.active ws.title = "Детальный_отчет" ws["B2"] = "Дата:" ws["D2"] = date_str ws["B2"].font = Font(name="Arial", size=10, bold=True) ws["D2"].font = Font(name="Arial", size=10, bold=True) headers = [ "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "находился в здании", "причина отсутствия", "норма", "отклонение от нормы" ] ws.append([]) ws.append(headers) header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") for col_idx in range(1, len(headers) + 1): cell = ws.cell(row=4, column=col_idx) cell.fill = header_fill cell.font = Font(name="Arial", size=10, bold=True) cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) apply_borders_to_cell(cell) start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня' end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня' hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании' chars_per_line_h = 24 for idx, row in merged_df.reset_index(drop=True).iterrows(): is_present = row.get('Пришел', False) absence_reason = row.get('Вид_отсутствия', '') has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' anom_flag = row.get('anomaly_flag', 'NONE') 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'] dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) ws.append([ idx + 1, row.get('Сотрудник', ''), dept_scud_val, row.get(start_col, 'Нет входа'), first_act_val, row.get(end_col, 'Нет выхода'), in_building_str, absence_reason if has_reason else '', 8, deviation_val ]) row_num = 5 + idx # ЗАЛИВКА СТРОК: if is_present and has_reason: row_fill = GREEN_FILL elif not is_present and has_reason: row_fill = YELLOW_FILL elif not is_present and not has_reason and not has_first_act: # Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы) row_fill = LIGHT_RED_FILL else: row_fill = None val_h_str = str(absence_reason) if has_reason else "" if len(val_h_str) > chars_per_line_h: needed_lines = math.ceil(len(val_h_str) / chars_per_line_h) ws.row_dimensions[row_num].height = max(needed_lines * 18, 22) else: ws.row_dimensions[row_num].height = 20 for col_idx in range(1, len(headers) + 1): cell = ws.cell(row=row_num, column=col_idx) apply_borders_to_cell(cell) if row_fill: cell.fill = row_fill if col_idx == 8: cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) elif col_idx in [1, 4, 5, 6, 7, 9, 10]: cell.alignment = Alignment(horizontal="center", vertical="center") else: cell.alignment = Alignment(horizontal="left", vertical="center") # Динамический компактный автоподгон ширины колонок for col in ws.columns: col_letter = get_column_letter(col[0].column) max_len = 0 for cell in col: if cell.value is not None: cell_lines = str(cell.value).split("\n") line_max = max(len(line) for line in cell_lines) if line_max > max_len: max_len = line_max optimal_width = max(max_len + 2, 8) if optimal_width > 35: optimal_width = 35 ws.column_dimensions[col_letter].width = optimal_width try: wb.save(output_path) print(f"[✓] Детальный отчет сохранен: {output_path}") except PermissionError: alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_path = os.path.join(OUTPUT_DIR, alt_filename) wb.save(alt_path) print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): output_path = os.path.join(OUTPUT_DIR, filename) df_scud.to_excel(output_path, index=False)