import math import os import time import openpyxl import pandas as pd from datetime import datetime, timedelta from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils import get_column_letter 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 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_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", 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") FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "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") def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]: """ ⭐️ Правило 8.5ч: при наличии входа и отсутствии выхода рассчитывает время выхода (Вход + 8ч 30мин) с нормой 8:00 и отклонением 0:00. """ 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): 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="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 = 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) ws.cell(row=1, column=1, value="Сводка на") ws.cell(row=1, column=2, value=date_clean) 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)) 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 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 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 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) & (~is_exc) ] 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 present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] 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 remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] ws.cell(row=current_row, column=1, value="В том числе на удаленной работе") ws.cell(row=current_row, column=2, value=len(remote_home)) format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font) current_row += 1 if not remote_home.empty: for fio in sorted(remote_home['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 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') ) ] 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") lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1 ws.row_dimensions[current_row].height = max(lines_count * 18, 20) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 exceptions_df = merged_df[is_exc] ws.cell(row=current_row, column=1, value="Исключения") ws.cell(row=current_row, column=2, value=len(exceptions_df)) format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font) current_row += 1 if not exceptions_df.empty: for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False) 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, OSError): alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_path = os.path.join(target_dir, alt_filename) wb.save(alt_path) print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") # --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ПРАВИЛОМ 8.5ч) --- 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 = Workbook() ws = wb.active ws.title = "Детальный_отчет" ws["B2"] = "Дата:" ws["D2"] = date_clean 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 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(): 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'] # ⭐️ ПРАВИЛО 8.5ч: если есть вход, но нет выхода (и нет официального документа отсутствия) 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('Подразделение', ''))) ws.append([ idx + 1, row.get('Сотрудник', ''), dept_scud_val, in_val, first_act_val, out_val, 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 "" lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1 ws.row_dimensions[row_num].height = max(lines_count * 18, 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 = max((len(str(cell.value or '')) for cell in col), default=8) ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 35) try: wb.save(output_path) print(f"[✓] Детальный отчет сохранен: {output_path}") except (PermissionError, OSError): alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_path = os.path.join(target_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(REPORTS_DIR, filename) df_scud.to_excel(output_path, index=False)