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") 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") 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 # 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, нумерация 1. ФИО) unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())] 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 idx, fio in enumerate(sorted(unexplained['Сотрудник'].dropna().unique()), 1): ws.cell(row=current_row, column=1, value=f"{idx}. {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. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) absent_only = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna())] 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 idx, fio in enumerate(sorted(group['Сотрудник'].dropna().unique()), 1): ws.cell(row=current_row, column=1, value=f"{idx}. {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 # 🎯 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа) 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 # 🚨 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список) # Столбец A: f"{idx}. {fio} (На работе)" # Столбец B: f"В 1С: {reason}" # Ширина столбца B = 32.0 (ровно 230px). Автоперенос + динамическая высота строк. anomalies = merged_df[(merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())] ws.cell(row=current_row, column=1, value="Аномалии") 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 # Влезает символов в столбец B шириной 230px (32.0 units) if not anomalies.empty: for idx, (_, row) in enumerate(anomalies.iterrows(), 1): fio = row.get('Сотрудник', '') reason = row.get('Вид_отсутствия', 'Неизвестная причина') reason_text = f"В 1С: {reason}" cell_a = ws.cell(row=current_row, column=1, value=f"{idx}. {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) # Автоперенос текста в столбце 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 # 📏 Настройка ширины колонок: B = 32.0 (ровно 230 пикселей) ws.column_dimensions['A'].width = 45.0 ws.column_dimensions['B'].width = 32.0 # Ровно 230px в Excel 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_g = 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() != '' ws.append([ idx + 1, row.get('Сотрудник', ''), row.get('Подразделение', ''), row.get(start_col, 'Нет входа'), row.get(end_col, 'Нет выхода'), row.get(hours_col, '0:00'), absence_reason if has_reason else '', 8, "-8:00" if not is_present else "0:00" ]) row_num = 5 + idx row_fill = None if not is_present: row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL val_g_str = str(absence_reason) if has_reason else "" if len(val_g_str) > chars_per_line_g: needed_lines = math.ceil(len(val_g_str) / chars_per_line_g) 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 == 7: cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) elif col_idx in [1, 4, 5, 6, 8, 9]: 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) if col_letter == 'G': ws.column_dimensions['G'].width = 25.0 else: max_len = 0 for cell in col: if cell.value is not None: line_max = max(len(l) for l in str(cell.value).split("\n")) if line_max > max_len: max_len = line_max ws.column_dimensions[col_letter].width = max(max_len + 4, 12) 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)