feat(reports): add simplified report builder, fix nan/inf crash and separate unhired/no-pass staff

This commit is contained in:
2026-09-24 09:25:57 +03:00
parent 9c89b6592e
commit 30651672ab
10 changed files with 437 additions and 89 deletions
+33 -9
View File
@@ -18,12 +18,17 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
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()
is_exc = merged_df.get('is_excluded', False) == True
is_not_hired = merged_df.get('not_hired_yet', False) == True
# Исключаем из отчета за вчера сотрудников-исключений И тех, кто еще не принят на работу
df_export = merged_df[(~is_exc) & (~is_not_hired)].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)
# Включаем опцию защиты nan_inf_to_errors
wb = xlsxwriter.Workbook(output_path, {'nan_inf_to_errors': True})
ws = wb.add_worksheet("Детальный_отчет")
fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True})
@@ -46,18 +51,32 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
for idx, row in df_export.reset_index(drop=True).iterrows():
row_num = 4 + idx
is_present = row.get('Пришел', False)
is_present = bool(row.get('Пришел', False))
absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() not in ['', 'nan', 'None']
in_val = str(row.get(start_col, 'Нет входа')).strip()
if in_val.lower() in ['nan', 'none']: in_val = 'Нет входа'
out_val = str(row.get(end_col, 'Нет выхода')).strip()
in_building_str = str(row.get(hours_col, '00:00'))
if out_val.lower() in ['nan', 'none']: out_val = 'Нет выхода'
in_building_str = str(row.get(hours_col, '00:00')).strip()
if in_building_str.lower() in ['nan', 'none']: in_building_str = '00:00'
first_act_val = str(row.get('Первая_активность', '—')).strip()
if first_act_val.lower() in ['nan', 'none']: first_act_val = '—'
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('Подразделение', '')))
dept_raw = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
dept_scud_val = str(dept_raw).strip() if pd.notna(dept_raw) else '—'
if dept_scud_val.lower() in ['nan', 'none']: dept_scud_val = '—'
fio_raw = row.get('Сотрудник', '')
fio_val = str(fio_raw).strip() if pd.notna(fio_raw) else ''
if fio_val.lower() in ['nan', 'none']: fio_val = ''
row_color = None
if is_present and has_reason:
@@ -67,24 +86,29 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
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 ""
val_h_str = str(absence_reason).strip() if has_reason else ""
if val_h_str.lower() in ['nan', 'none']: val_h_str = ""
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),
(fio_val, '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),
(val_h_str, 'left', True),
(8, 'center', False),
(deviation_val, 'center', False)
]
for col_idx, (val, align_type, is_wrap) in enumerate(values):
# Санитизация для исключения передачи float('nan') в _write_number
if pd.isna(val) or val is None or str(val).strip().lower() == 'nan':
val = ""
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)
+147
View File
@@ -0,0 +1,147 @@
"""
===============================================================================
FILE: services/reports/simplified_builder.py
ROLE: Генератор книги "Упрощенный отчет за ДД.ММ.ГГГГг..xlsx"
на основе агрегированных данных СКУД и 1С.
===============================================================================
"""
import os
import openpyxl
from openpyxl.styles import Font, Alignment, Border, Side
from datetime import datetime, time
import pandas as pd
from config import REPORTS_DIR
from services.reports.styles import get_dated_reports_dir
def format_fio_initials(full_fio: str) -> str:
"""Преобразует 'Иванов Иван Иванович' в 'Иванов И.И.'"""
if not full_fio or pd.isna(full_fio):
return ""
parts = str(full_fio).strip().split()
if len(parts) >= 3:
return f"{parts[0]} {parts[1][0]}.{parts[2][0]}."
elif len(parts) == 2:
return f"{parts[0]} {parts[1][0]}."
return str(full_fio).strip()
def parse_time_cell(val, default_empty="Нет входа (0:00)"):
"""Преобразует строку времени СКУД в time(hh, mm) либо оставляет текстовую заглушку."""
if not val or pd.isna(val):
return default_empty
s = str(val).strip()
if s in ["Нет входа", "—", "", "nan", "None", "00:00:00", "00:00"]:
return default_empty
if s in ["Нет выхода"]:
return "Нет выхода (23:59)"
# Если уже пришёл time
if isinstance(val, time):
return val
try:
parts = s.split(":")
h = int(parts[0])
m = int(parts[1]) if len(parts) > 1 else 0
return time(h, m)
except Exception:
return s
def generate_simplified_excel(df_merged: pd.DataFrame, date_str: str, filename: str = None) -> str:
"""
Генерирует Excel-файл упрощенного отчета точно по образцу Ориона.
Структура:
A: № | B: Подразделение | C: Сотрудник | D: Должность | E: Начало дня | F: Конец дня
"""
clean_date = str(date_str).replace('_', '.')
if not filename:
filename = f"Упрощенный отчет за {clean_date}г..xlsx"
target_dir = get_dated_reports_dir(clean_date)
output_path = os.path.join(target_dir, filename)
# Исключаем тех, кто не принят на работу и исключения
is_exc = df_merged.get('is_excluded', False) == True
is_not_hired = df_merged.get('not_hired_yet', False) == True
df_src = df_merged[(~is_exc) & (~is_not_hired)].copy()
# Подготовка данных
rows_to_sort = []
for _, r in df_src.iterrows():
fio_raw = r.get('Сотрудник', r.get('ФИО', ''))
dept = str(r.get('Подразделение', '—')).strip()
pos = str(r.get('Должность', '—')).strip()
if pos.lower() in ['nan', 'none', '']: pos = '—'
if dept.lower() in ['nan', 'none', '']: dept = '—'
fio_short = format_fio_initials(fio_raw)
t_in = parse_time_cell(r.get('Начало_дня'), default_empty="Нет входа (0:00)")
t_out = parse_time_cell(r.get('Конец_дня'), default_empty="Нет выхода (23:59)")
rows_to_sort.append({
'dept': dept,
'fio_short': fio_short,
'pos': pos,
't_in': t_in,
't_out': t_out
})
# Сортировка: Подразделение (А-Я), затем Сотрудник (А-Я)
rows_sorted = sorted(rows_to_sort, key=lambda x: (x['dept'].lower(), x['fio_short'].lower()))
# Создание книги openpyxl для точного соблюдения структуры образца
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Лист1"
# Стили по образцу
font_main = Font(name="Calibri", size=11, bold=False)
align_center = Alignment(horizontal="center", vertical="center")
border_thin = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin")
)
# Заголовок
ws["B1"] = f"Упрощенный отчет: c {clean_date} по {clean_date}"
ws["B1"].font = font_main
# Шапка таблицы (Строка 3)
headers = [None, "Подразделение", "Сотрудник", "Должность", "Начало дня", "Конец дня"]
for col_idx, h_text in enumerate(headers, start=1):
cell = ws.cell(row=3, column=col_idx, value=h_text)
cell.font = font_main
cell.alignment = align_center
cell.border = border_thin
# Заполнение строк данных (с 4 строки)
for idx, item in enumerate(rows_sorted, start=1):
row_num = 3 + idx
vals = [idx, item['dept'], item['fio_short'], item['pos'], item['t_in'], item['t_out']]
for col_idx, val in enumerate(vals, start=1):
cell = ws.cell(row=row_num, column=col_idx, value=val)
cell.font = font_main
cell.alignment = align_center
cell.border = border_thin
# Числовой формат времени h:mm
if isinstance(val, time):
cell.number_format = "h:mm"
# Настройка ширины колонок по образцу
widths = {'A': 13.0, 'B': 25.0, 'C': 27.0, 'D': 80.0, 'E': 21.5, 'F': 23.3}
for col_letter, w in widths.items():
ws.column_dimensions[col_letter].width = w
# Создание пустых Лист2 и Лист3 как в оригинальном шаблоне
wb.create_sheet("Лист2")
wb.create_sheet("Лист3")
wb.save(output_path)
return output_path
+52 -24
View File
@@ -20,7 +20,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename)
wb = xlsxwriter.Workbook(output_path)
wb = xlsxwriter.Workbook(output_path, {'nan_inf_to_errors': True})
ws = wb.add_worksheet("Лист_1")
ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False)
@@ -38,18 +38,24 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
ws.write(1, 0, "", fmt_empty)
ws.write(1, 1, "", fmt_empty)
is_not_hired = merged_df.get('not_hired_yet', False) == True
is_no_pass = merged_df.get('no_scud_pass', False) == True
is_exc = merged_df.get('is_excluded', False) == True
# "По списку" строго по официальному штату 1С
staff_total = len(merged_df[~is_not_hired])
ws.set_row(2, 20)
ws.write(2, 0, "По списку", fmt_tot_l)
ws.write(2, 1, len(merged_df), fmt_tot_r)
ws.write(2, 1, staff_total, 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[
(~is_not_hired) &
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'None']))) &
(~is_no_pass) & (~is_exc)
]
fmt_unexp_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left")
@@ -64,34 +70,54 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
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, 0, str(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()
# 2. Нет пропуска (в штате 1С есть, но карты СКУД нет)
no_pass_df = merged_df[is_no_pass & (~is_exc) & (~is_not_hired)]
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, 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, 0, str(fio), fmt_np_rl)
ws.write(current_row, 1, "", fmt_np_rr)
current_row += 1
# 3. Официальные отсутствия
# 3. Не приняты на работу (в СКУД есть, но в 1С приказа еще нет)
not_hired_df = merged_df[is_not_hired & (~is_exc)]
fmt_nh_hl = create_xlsx_format(wb, bg_color='#FFF0F5', bold=True, align="left")
fmt_nh_hr = create_xlsx_format(wb, bg_color='#FFF0F5', bold=True, align="right")
fmt_nh_rl = create_xlsx_format(wb, bg_color='#FFF0F5', bold=False, align="left")
fmt_nh_rr = create_xlsx_format(wb, bg_color='#FFF0F5', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Нет в ЗУП", fmt_nh_hl)
ws.write(current_row, 1, len(not_hired_df), fmt_nh_hr)
current_row += 1
if not not_hired_df.empty:
for fio in sorted(not_hired_df['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
ws.write(current_row, 0, str(fio), fmt_nh_rl)
ws.write(current_row, 1, "", fmt_nh_rr)
current_row += 1
# 4. Официальные отсутствия
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
absent_only = merged_df[
(~is_not_hired) &
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
@@ -108,7 +134,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
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, 0, str(cat_name), fmt_cat_hl)
ws.write(current_row, 1, len(group), fmt_cat_hr)
current_row += 1
@@ -116,14 +142,16 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
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 ""
if pd.isna(detail_val):
detail_val = ""
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)
ws.write(current_row, 0, str(fio), fmt_cat_rl)
ws.write(current_row, 1, str(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)]
# 5. Итого на работе (только принятые)
exc_without_doc = merged_df[(~is_not_hired) & is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))]
present_scud = merged_df[(~is_not_hired) & (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")
@@ -134,8 +162,8 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
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)]
# 6. Удаленная работа
remote_home = merged_df[(~is_not_hired) & (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")
@@ -149,13 +177,13 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
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, 0, str(fio), fmt_rem_rl)
ws.write(current_row, 1, "", fmt_rem_rr)
current_row += 1
# 6. Аномалии СКУД и 1С
# 7. Аномалии СКУД и 1С
anomalies = merged_df[
(~is_exc) & (
(~is_not_hired) & (~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason) &
@@ -182,8 +210,8 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
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)
ws.write(current_row, 0, str(fio), fmt_anom_rl)
ws.write(current_row, 1, str(reason_text), fmt_anom_rr)
current_row += 1
ws.set_column(0, 0, 45)