From aa1141fb8881af0ee6b45c683d4a2ca4ba1aa06f Mon Sep 17 00:00:00 2001 From: manoraga Date: Fri, 11 Sep 2026 15:29:48 +0300 Subject: [PATCH] =?UTF-8?q?refactor(reports):=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20excel=5Fexporter=20=D0=BD?= =?UTF-8?q?=D0=B0=20styles,=20calculators=20=D0=B8=20=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D1=8B=20=D0=BE=D1=82=D1=87=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/excel_exporter.py | 516 +-------------------------- services/reports/__init__.py | 0 services/reports/calculators.py | 56 +++ services/reports/otchet_builder.py | 95 +++++ services/reports/raw_scud_builder.py | 42 +++ services/reports/styles.py | 80 +++++ services/reports/svodka_builder.py | 191 ++++++++++ 7 files changed, 477 insertions(+), 503 deletions(-) create mode 100644 services/reports/__init__.py create mode 100644 services/reports/calculators.py create mode 100644 services/reports/otchet_builder.py create mode 100644 services/reports/raw_scud_builder.py create mode 100644 services/reports/styles.py create mode 100644 services/reports/svodka_builder.py diff --git a/services/excel_exporter.py b/services/excel_exporter.py index 590984d..72068cf 100644 --- a/services/excel_exporter.py +++ b/services/excel_exporter.py @@ -1,509 +1,19 @@ """ =============================================================================== FILE: services/excel_exporter.py -ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter. - Корректный расчет часов удаленщиков и исключение лишних списков. +ROLE: Единый фасад генераторов Excel-отчетов (обратная совместимость). =============================================================================== """ -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_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. Официальные отсутствия - 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 - - is_other_category = (str(cat_name).strip().lower() == "иное") - - for _, row in group.sort_values(by='Сотрудник').iterrows(): - fio = row.get('Сотрудник', '') - # Если категория "Иное" — берем детальную причину из manual_absences / detailed_reason - detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else "" - - 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, detail_val, 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'] - - # Автозакрытие отключено по согласованию с ОК: сохраняем факт отсутствия выхода - 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) \ No newline at end of file +from services.reports.styles import ( + format_date_ru, + get_dated_reports_dir, + safe_close_workbook, + create_xlsx_format +) +from services.reports.calculators import calculate_deviation + +# Реэкспорт функций генерации +from services.reports.svodka_builder import generate_summary_excel +from services.reports.otchet_builder import generate_detailed_excel +from services.reports.raw_scud_builder import export_raw_scud \ No newline at end of file diff --git a/services/reports/__init__.py b/services/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/reports/calculators.py b/services/reports/calculators.py new file mode 100644 index 0000000..db41d0b --- /dev/null +++ b/services/reports/calculators.py @@ -0,0 +1,56 @@ +""" +=============================================================================== +FILE: services/reports/calculators.py +ROLE: Расчет баланса рабочего времени, обеденного перерыва и отклонений от нормы. +=============================================================================== +""" + +import pandas as pd + + +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" \ No newline at end of file diff --git a/services/reports/otchet_builder.py b/services/reports/otchet_builder.py new file mode 100644 index 0000000..f56d2bf --- /dev/null +++ b/services/reports/otchet_builder.py @@ -0,0 +1,95 @@ +""" +=============================================================================== +FILE: services/reports/otchet_builder.py +ROLE: Генератор книги Детального суточного отчета со сверкой 1С:ЗУП. +=============================================================================== +""" + +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 +from services.reports.calculators import calculate_deviation + + +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" + + df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() if merged_df is not None and not merged_df.empty else 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("Детальный_отчет") + + 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 = create_xlsx_format(wb, font_name='Arial', font_size=10, 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'] + + 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 = create_xlsx_format(wb, font_name='Arial', font_size=10, 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) \ No newline at end of file diff --git a/services/reports/raw_scud_builder.py b/services/reports/raw_scud_builder.py new file mode 100644 index 0000000..ca1bde2 --- /dev/null +++ b/services/reports/raw_scud_builder.py @@ -0,0 +1,42 @@ +""" +=============================================================================== +FILE: services/reports/raw_scud_builder.py +ROLE: Генерация Excel-файла сырых данных СКУД. +=============================================================================== +""" + +import os +import pandas as pd +import xlsxwriter +from config import REPORTS_DIR +from services.reports.styles import safe_close_workbook, create_xlsx_format + + +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 = create_xlsx_format(wb, bold=True, bg_color='#D9E1F2', align='center') + fmt_cell = create_xlsx_format(wb, align='left') + + headers = list(df_scud.columns) + ws.set_row(0, 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): + val_str = "" if (pd.isna(val) or val is None) else ("Да" if isinstance(val, bool) and val else ("Нет" if isinstance(val, bool) else 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) \ No newline at end of file diff --git a/services/reports/styles.py b/services/reports/styles.py new file mode 100644 index 0000000..1c04723 --- /dev/null +++ b/services/reports/styles.py @@ -0,0 +1,80 @@ +""" +=============================================================================== +FILE: services/reports/styles.py +ROLE: Стили, палитры цветов, форматирование дат и защита от блокировок Excel. +=============================================================================== +""" + +import os +import time +from datetime import datetime +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() + 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() + return alt_path + except Exception: + return output_path + + +def create_xlsx_format(workbook, font_name="Calibri", font_size=11, bg_color=None, bold=False, align="left", wrap=False): + fmt_dict = { + 'font_name': font_name, + 'font_size': font_size, + 'bold': bold, + 'align': align, + 'valign': 'vcenter', + 'border': 1, + 'border_color': '#D3D3D3', + 'text_wrap': wrap + } + if bg_color: + fmt_dict['bg_color'] = bg_color + return workbook.add_format(fmt_dict) \ No newline at end of file diff --git a/services/reports/svodka_builder.py b/services/reports/svodka_builder.py new file mode 100644 index 0000000..6290283 --- /dev/null +++ b/services/reports/svodka_builder.py @@ -0,0 +1,191 @@ +""" +=============================================================================== +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) + 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) + + 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 = 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, 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 = 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, fio, fmt_np_rl) + ws.write(current_row, 1, "", fmt_np_rr) + current_row += 1 + + # 3. Официальные отсутствия + 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 = 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, 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 "" + 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, detail_val, 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 = 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 + + # 5. Удаленная работа + remote_home = merged_df[(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, 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 = 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, 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) \ No newline at end of file