Files
scud_ai/services/excel_exporter.py
T

449 lines
20 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.
import math
import os
import time
import openpyxl
import pandas as pd
from datetime import datetime, timedelta
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
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
THIN_SIDE = Side(border_style="thin", color="D3D3D3")
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE)
FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid")
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid")
FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid")
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"]
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]:
"""
⭐️ Правило 8.5ч: при наличии входа и отсутствии выхода
рассчитывает время выхода (Вход + 8ч 30мин) с нормой 8:00 и отклонением 0:00.
"""
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):
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
return "0:00"
if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']:
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 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 f"-{norm_hours}:00"
def apply_borders_to_cell(cell, border=THIN_BORDER):
cell.border = border
def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False):
cell_a = ws.cell(row=r_num, column=1)
cell_b = ws.cell(row=r_num, column=2)
if fill_obj:
cell_a.fill = fill_obj
cell_b.fill = fill_obj
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
if is_bold and bold_font:
cell_a.font = bold_font
cell_b.font = bold_font
if align_b:
cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
# --- 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 = Workbook()
ws = wb.active
ws.title = "Лист_1"
ws.sheet_properties.outlinePr.summaryBelow = False
ws.sheet_properties.outlinePr.summaryRight = False
ws.sheet_properties.outlinePr.showOutlineSymbols = True
ws.sheet_view.showOutlineSymbols = True
bold_font = Font(name="Calibri", size=11, bold=True)
ws.cell(row=1, column=1, value="Сводка на")
ws.cell(row=1, column=2, value=date_clean)
format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font)
apply_borders_to_cell(ws.cell(row=2, column=1))
apply_borders_to_cell(ws.cell(row=2, column=2))
ws.cell(row=3, column=1, value="По списку")
ws.cell(row=3, column=2, value=len(merged_df))
format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font)
current_row = 4
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
unexplained = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass) &
(~is_exc)
]
ws.cell(row=current_row, column=1, value="неизвестно")
ws.cell(row=current_row, column=2, value=len(unexplained))
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False
current_row += 1
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
ws.cell(row=current_row, column=1, value="Нет пропуска")
ws.cell(row=current_row, column=2, value=len(no_pass_df))
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font)
current_row += 1
if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False
current_row += 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) &
(~is_exc)
]
absent_groups = absent_only.groupby('Вид_отсутствия')
for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)]
cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid")
ws.cell(row=current_row, column=1, value=cat_name)
ws.cell(row=current_row, column=2, value=len(group))
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font)
current_row += 1
for fio in sorted(group['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, cat_fill, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
ws.cell(row=current_row, column=1, value="Итого на работе")
ws.cell(row=current_row, column=2, value=len(present))
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
current_row += 1
if not present.empty:
for fio in sorted(present['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
ws.cell(row=current_row, column=1, value="В том числе на удаленной работе")
ws.cell(row=current_row, column=2, value=len(remote_home))
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font)
current_row += 1
if not remote_home.empty:
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 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')
)
]
ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С")
ws.cell(row=current_row, column=2, value=len(anomalies))
format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font)
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}"
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY
cell_b.fill = FILL_ANOMALY
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center")
lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
ws.row_dimensions[current_row].height = max(lines_count * 18, 20)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
exceptions_df = merged_df[is_exc]
ws.cell(row=current_row, column=1, value="Исключения")
ws.cell(row=current_row, column=2, value=len(exceptions_df))
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font)
current_row += 1
if not exceptions_df.empty:
for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
ws.column_dimensions['A'].width = 45.0
ws.column_dimensions['B'].width = 38.0
try:
wb.save(output_path)
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except (PermissionError, OSError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ПРАВИЛОМ 8.5ч) ---
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 = Workbook()
ws = wb.active
ws.title = "Детальный_отчет"
ws["B2"] = "Дата:"
ws["D2"] = date_clean
ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True)
headers = [
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
]
ws.append([])
ws.append(headers)
header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=4, column=col_idx)
cell.fill = header_fill
cell.font = Font(name="Arial", size=10, bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
apply_borders_to_cell(cell)
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():
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']
# ⭐️ ПРАВИЛО 8.5ч: если есть вход, но нет выхода (и нет официального документа отсутствия)
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('Подразделение', '')))
ws.append([
idx + 1,
row.get('Сотрудник', ''),
dept_scud_val,
in_val,
first_act_val,
out_val,
in_building_str,
absence_reason if has_reason else '',
8,
deviation_val
])
row_num = 5 + idx
if is_present and has_reason:
row_fill = GREEN_FILL
elif not is_present and has_reason:
row_fill = YELLOW_FILL
elif not is_present and not has_reason and not has_first_act:
row_fill = LIGHT_RED_FILL
else:
row_fill = None
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.row_dimensions[row_num].height = max(lines_count * 18, 20)
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=row_num, column=col_idx)
apply_borders_to_cell(cell)
if row_fill:
cell.fill = row_fill
if col_idx == 8:
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
elif col_idx in [1, 4, 5, 6, 7, 9, 10]:
cell.alignment = Alignment(horizontal="center", vertical="center")
else:
cell.alignment = Alignment(horizontal="left", vertical="center")
for col in ws.columns:
col_letter = get_column_letter(col[0].column)
max_len = max((len(str(cell.value or '')) for cell in col), default=8)
ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 35)
try:
wb.save(output_path)
print(f"[✓] Детальный отчет сохранен: {output_path}")
except (PermissionError, OSError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
output_path = os.path.join(REPORTS_DIR, filename)
df_scud.to_excel(output_path, index=False)