Files
scud_ai/services/excel_exporter.py
T

514 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
===============================================================================
FILE: services/excel_exporter.py
ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter.
Корректный расчет часов удаленщиков и исключение лишних списков.
===============================================================================
"""
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_autoclose_time(time_in_str: str) -> tuple[str, str, str]:
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):
"""
Расчет отклонения от нормы.
Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение.
"""
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. Официальные отсутствия (Сотрудники из исключений при наличии документа 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)
]
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
for fio in sorted(group['Сотрудник'].dropna().unique()):
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, "", 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']
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('Подразделение', '')))
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)