feat(reports): add simplified report builder, fix nan/inf crash and separate unhired/no-pass staff
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user