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
+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