514 lines
21 KiB
Python
514 lines
21 KiB
Python
"""
|
||
===============================================================================
|
||
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) |