refactor(reports): модульное разделение excel_exporter на styles, calculators и билдеры отчетов
This commit is contained in:
+11
-501
@@ -1,509 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
===============================================================================
|
===============================================================================
|
||||||
FILE: services/excel_exporter.py
|
FILE: services/excel_exporter.py
|
||||||
ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter.
|
ROLE: Единый фасад генераторов Excel-отчетов (обратная совместимость).
|
||||||
Корректный расчет часов удаленщиков и исключение лишних списков.
|
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
from services.reports.styles import (
|
||||||
import math
|
format_date_ru,
|
||||||
import time
|
get_dated_reports_dir,
|
||||||
import pandas as pd
|
safe_close_workbook,
|
||||||
import xlsxwriter
|
create_xlsx_format
|
||||||
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')
|
|
||||||
)
|
)
|
||||||
]
|
from services.reports.calculators import calculate_deviation
|
||||||
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)
|
from services.reports.svodka_builder import generate_summary_excel
|
||||||
ws.write(current_row, 1, len(anomalies), fmt_anom_hr)
|
from services.reports.otchet_builder import generate_detailed_excel
|
||||||
current_row += 1
|
from services.reports.raw_scud_builder import export_raw_scud
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user