перед настройкой выгрузки из СКУД, вместо ps1.

This commit is contained in:
2026-07-28 13:57:50 +03:00
parent 45f9b78d5c
commit 2b9e54580b
5 changed files with 208 additions and 48 deletions
+10
View File
@@ -0,0 +1,10 @@
fio,reason,note
Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка
Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка
Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка
Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка
Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка
Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
1 fio reason note
2 Королёва Наталья Александровна Удаленная работа Постоянная удаленка
3 Николаева Ирина Леонидовна Удаленная работа Постоянная удаленка
4 Познякова Татьяна Сергеевна Удаленная работа Постоянная удаленка
5 Софьин Никита Сергеевич Удаленная работа Постоянная удаленка
6 Чуб Александр Васильевич Удаленная работа Постоянная удаленка
7 Шуличенко Иван Иванович Удаленная работа Постоянная удаленка
8 Пухаренко Юрий Владимирович Удаленная работа Постоянная удаленка
9 Пшеничный Виктор Петрович Удаленная работа Постоянная удаленка
10 Незнанова Валерия Игоревна Удаленная работа Постоянная удаленка
+2 -3
View File
@@ -7,9 +7,8 @@
"Уборщик служебных помещений" "Уборщик служебных помещений"
], ],
"fio": [ "fio": [
"Шуличенко Иван Иванович", "Таткало Валерий Валерьевич",
"Таткало Валерий Валерьевич", "Петренюк Андрей Германович",
"Пухаренко Юрий Владимирович",
"Чуркина Елена Геннадьевна", "Чуркина Елена Геннадьевна",
"Михалев Сергей Геннадьевич" "Михалев Сергей Геннадьевич"
], ],
+124 -22
View File
@@ -1,21 +1,76 @@
import os import os
import sys import sys
import argparse
import pandas as pd import pandas as pd
sys.path.append(os.path.dirname(os.path.abspath(__file__))) sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio
from modules.data_validator import check_file_freshness from modules.data_validator import check_file_freshness
from modules.data_loader import load_all_data from modules.data_loader import load_all_data
from modules.excel_exporter import generate_summary_excel, generate_detailed_excel from modules.excel_exporter import generate_summary_excel, generate_detailed_excel
from modules.ai_verifier import ai_verify_scud_against_staff from modules.ai_verifier import ai_verify_scud_against_staff
from modules.text_reporter import generate_markdown_report from modules.text_reporter import generate_markdown_report
from modules.knowledge_base import load_knowledge_base from modules.feedback_loop import review_ai_decisions
def load_static_reason_workers():
"""
Загружает реестр сотрудников со статическими/особыми причинами
из static_reason_workers.csv.
Возвращает словарь: {fio_clean: reason}
"""
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if not os.path.exists(static_path):
return {}
try:
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
return dict(zip(df_static['fio_clean'], df_static['reason']))
except Exception as e:
print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}")
return {}
def aggregate_scud_by_employee(df, debug=False):
"""
Агрегирует записи СКУД по сотруднику (fio_clean).
Устраняет дубликаты карт/табельных номеров (например, Кондрашова Е.В.):
Если человек пришел хотя бы по одной карте — Пришел = True.
"""
if df.empty or 'fio_clean' not in df.columns:
return df
aggregated = []
for fio_clean, group in df.groupby('fio_clean', sort=False):
is_present = group['Пришел'].any() if 'Пришел' in group.columns else False
if is_present and 'Пришел' in group.columns:
present_rows = group[group['Пришел'] == True]
best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict()
else:
best_row = group.iloc[0].to_dict()
best_row['Пришел'] = is_present
if debug and len(group) > 1:
print(f" [DEBUG] Найдено дубликатов карт/записей для {fio_clean}: {len(group)} шт. Статус присутствия: {is_present}")
aggregated.append(best_row)
return pd.DataFrame(aggregated)
def main(): def main():
parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С")
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки (подробный вывод)")
args = parser.parse_args()
DEBUG = args.debug
print("=" * 60) print("=" * 60)
print("ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С") print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
print("=" * 60) print("=" * 60)
# 0. Валидация файлов # 0. Валидация файлов
@@ -38,11 +93,18 @@ def main():
print("[✓] Проверка актуальности успешно пройдена!\n") print("[✓] Проверка актуальности успешно пройдена!\n")
# 1. Загрузка # 1. Загрузка данных
print("[1/5] Загрузка данных СКУД, 1С и исключений...") print("[1/5] Загрузка данных СКУД, 1С, реестра статических причин и исключений...")
raw_scud_df, staff_fios_clean, df_absent = load_all_data() raw_scud_df, staff_fios_clean, df_absent = load_all_data()
static_reasons_dict = load_static_reason_workers()
# 2. ИИ-сверка if DEBUG:
print(f" [DEBUG] Загружено записей СКУД: {len(raw_scud_df)}")
print(f" [DEBUG] Загружено сотрудников из 1С: {len(staff_fios_clean)}")
print(f" [DEBUG] Загружено докум. отсутствий 1С: {len(df_absent)}")
print(f" [DEBUG] Загружено статических причин: {len(static_reasons_dict)}")
# 2. ИИ-сверка ФИО
print("[2/5] Проверка опечаток и сопоставление ФИО через ИИ...") print("[2/5] Проверка опечаток и сопоставление ФИО через ИИ...")
scud_unrecognized = raw_scud_df[~raw_scud_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() scud_unrecognized = raw_scud_df[~raw_scud_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist()
fio_mapping = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean) fio_mapping = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean)
@@ -53,30 +115,55 @@ def main():
lambda x: fio_mapping[x]['staff_fio'] if x in fio_mapping else x lambda x: fio_mapping[x]['staff_fio'] if x in fio_mapping else x
) )
# 3. Объединение # 2.1 Агрегация нескольких карт/дубликатов
print("[3/5] Объединение реестров 1С и СКУД...") raw_scud_df = aggregate_scud_by_employee(raw_scud_df, debug=DEBUG)
# 3. Объединение данных и иерархия приоритетов
print("[3/5] Объединение реестров 1С, СКУД, статических причин и исключений...")
merged = raw_scud_df[raw_scud_df['fio_clean'].isin(staff_fios_clean)].copy() merged = raw_scud_df[raw_scud_df['fio_clean'].isin(staff_fios_clean)].copy()
# 🎯 ПРИОРИТЕТ 1: Выгрузка докум. отсутствий 1С:ЗУП (Отсутствия сотрудников - выгрузка.xlsx)
merged = merged.merge(df_absent, on='fio_clean', how='left') merged = merged.merge(df_absent, on='fio_clean', how='left')
# 🎯 ПРИОРИТЕТ 2: Статические причины из static_reason_workers.csv
# Применяются ТОЛЬКО если в 1С:ЗУП документа нет и сотрудник отсутствует в СКУД
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_apply_static = (
(merged['Пришел'] == False) &
(merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged['fio_clean'] == fio_clean)
)
merged.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val
# 🎯 ПРИОРИТЕТ 3: Исключения по должности/департаменту/ФИО (exceptions.json)
# Если сотрудник попал в исключения (is_excluded = True), НЕ пришел в СКУД и НЕ имеет документа в 1С:
# Присваиваем ему статус "Исключение (ОВК/Подрядчики)", чтобы он НЕ ПОПАДАЛ в неизвестные!
mask_apply_exceptions = (
(merged['Пришел'] == False) &
(merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged.get('is_excluded', False) == True)
)
merged.loc[mask_apply_exceptions, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
absent_explained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].notna())] absent_explained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].notna())]
absent_unexplained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna())] absent_unexplained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna())]
scud_present_but_absent_in_1c = merged[(merged['Пришел'] == True) & (merged['Вид_отсутствия'].notna())] scud_present_but_absent_in_1c = merged[(merged['Пришел'] == True) & (merged['Вид_отсутствия'].notna())]
# 4. Формирование Excel-файлов (автоматическое наименование вида "27 июля 2026 сводка.xlsx") if DEBUG:
print("[4/5] Формирование Excel-файлов...") print("\n--- [DEBUG STATS] ---")
print(f" Всего по списку: {len(merged)}")
generate_summary_excel( print(f" Присутствуют на рабочем месте: {len(merged[merged['Пришел'] == True])}")
merged_df=merged, print(f" Официально отсутствуют (1С + стат. причины + исключения): {len(absent_explained)}")
date_str=DATE_TODAY print(f" Истинно неизвестные случаи: {len(absent_unexplained)}")
) if not absent_unexplained.empty:
print(" Список неизвестных:")
for fio in absent_unexplained['Сотрудник'].dropna().tolist():
print(f" - {fio}")
print("---------------------\n")
generate_detailed_excel( # 4. ИИ-анализ и интерактивная проверка (HITL)
merged_df=merged, print("[4/5] Запуск ИИ-аудитора и проверка аномалий...")
date_str=DATE_YESTERDAY
)
# 5. Текстовый ИИ-отчет
print("[5/5] Запуск ИИ-аудитора...")
report_text = generate_markdown_report( report_text = generate_markdown_report(
merged_df=merged, merged_df=merged,
absent_explained=absent_explained, absent_explained=absent_explained,
@@ -86,6 +173,21 @@ def main():
date_str=DATE_TODAY date_str=DATE_TODAY
) )
suspicious_cases = []
if not scud_present_but_absent_in_1c.empty:
for _, row in scud_present_but_absent_in_1c.iterrows():
suspicious_cases.append({
'fio_target': row.get('Сотрудник', ''),
'reason': f"Пришел по СКУД, но в 1С зафиксировано: {row.get('Вид_отсутствия', '')}"
})
review_ai_decisions(report_text, suspicious_cases)
# 5. Формирование Excel и Markdown отчетов
print("[5/5] Формирование итоговых Excel и Markdown отчетов...")
generate_summary_excel(merged_df=merged, date_str=DATE_TODAY)
generate_detailed_excel(merged_df=merged, date_str=DATE_YESTERDAY)
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ:") print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ:")
print("=" * 60) print("=" * 60)
+3 -4
View File
@@ -57,19 +57,18 @@ def load_scud_data():
val_status = str(row.get(status_col, '')).strip().lower() if status_col else '' val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
val_time = str(row.get(start_col, '')).strip() if start_col else '' val_time = str(row.get(start_col, '')).strip() if start_col else ''
# Разделение: Слово "Отсутствовал" в СКУД или пустая метка времени = ФИЗИЧЕСКИ НЕ БЫЛО
if 'отсутствовал' in val_status or val_time in ['нет входа', 'none', 'nan', '', '00:00']: if 'отсутствовал' in val_status or val_time in ['нет входа', 'none', 'nan', '', '00:00']:
return False return False
# Во всех остальных случаях (есть время прохода) считаем, что человек ФИЗИЧЕСКИ переступил порог
return True return True
df_scud['Пришел'] = df_scud.apply(is_person_present, axis=1) df_scud['Пришел'] = df_scud.apply(is_person_present, axis=1)
# Отмечаем флаг исключений, но НЕ удаляем сразу
df_scud['is_excluded'] = df_scud.apply( df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions), lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions),
axis=1 axis=1
) )
return df_scud[~df_scud['is_excluded']].copy() return df_scud
def load_all_data(): def load_all_data():
df_scud = load_scud_data() df_scud = load_scud_data()
+69 -19
View File
@@ -33,6 +33,7 @@ FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="s
FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", 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_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"] CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"]
@@ -45,7 +46,7 @@ def apply_borders_to_cell(cell, border=THIN_BORDER):
cell.border = border cell.border = border
def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None): 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_a = ws.cell(row=r_num, column=1)
cell_b = ws.cell(row=r_num, column=2) cell_b = ws.cell(row=r_num, column=2)
if fill_obj: if fill_obj:
@@ -57,7 +58,7 @@ def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_f
cell_a.font = bold_font cell_a.font = bold_font
cell_b.font = bold_font cell_b.font = bold_font
if align_b: if align_b:
cell_b.alignment = Alignment(horizontal=align_b) cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
# --- 1. СВОДКА НА СЕГОДНЯ --- # --- 1. СВОДКА НА СЕГОДНЯ ---
@@ -70,11 +71,10 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
ws = wb.active ws = wb.active
ws.title = "Лист_1" ws.title = "Лист_1"
# 🎯 ДВОЙНОЕ ВКЛЮЧЕНИЕ СИМВОЛОВ СТРУКТУРЫ В EXCEL (Обязательно для вывода плюсиков)
ws.sheet_properties.outlinePr.summaryBelow = False ws.sheet_properties.outlinePr.summaryBelow = False
ws.sheet_properties.outlinePr.summaryRight = False ws.sheet_properties.outlinePr.summaryRight = False
ws.sheet_properties.outlinePr.showOutlineSymbols = True # Включает боковую панель ws.sheet_properties.outlinePr.showOutlineSymbols = True
ws.sheet_view.showOutlineSymbols = True # Отображает плюсики ws.sheet_view.showOutlineSymbols = True
bold_font = Font(name="Calibri", size=11, bold=True) bold_font = Font(name="Calibri", size=11, bold=True)
@@ -93,24 +93,26 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
current_row = 4 current_row = 4
# 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО: hidden=False) # 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, нумерация 1. ФИО)
unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())] unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())]
ws.cell(row=current_row, column=1, value="неизвестно") ws.cell(row=current_row, column=1, value="неизвестно")
ws.cell(row=current_row, column=2, value=len(unexplained)) 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) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
current_row += 1 current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()): for idx, fio in enumerate(sorted(unexplained['Сотрудник'].dropna().unique()), 1):
ws.cell(row=current_row, column=1, value=fio) ws.cell(row=current_row, column=1, value=f"{idx}. {fio}")
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True, под плюсики) # 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
absent_groups = merged_df[merged_df['Вид_отсутствия'].notna()].groupby('Вид_отсутствия') absent_only = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna())]
for idx, (cat_name, group) in enumerate(absent_groups): absent_groups = absent_only.groupby('Вид_отсутствия')
hex_color = CATEGORY_PASTEL_COLORS[idx % len(CATEGORY_PASTEL_COLORS)]
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") 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=1, value=cat_name)
@@ -118,21 +120,69 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font)
current_row += 1 current_row += 1
for fio in sorted(group['Сотрудник'].dropna().unique()): for idx, fio in enumerate(sorted(group['Сотрудник'].dropna().unique()), 1):
ws.cell(row=current_row, column=1, value=fio) ws.cell(row=current_row, column=1, value=f"{idx}. {fio}")
format_row_cells(ws, current_row, cat_fill, is_bold=False) format_row_cells(ws, current_row, cat_fill, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 5. ИТОГО НА РАБОТЕ # 🎯 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа)
present = merged_df[merged_df['Пришел'] == True] is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
)
present = merged_df[is_working_mask]
ws.cell(row=current_row, column=1, value="Итого на работе") ws.cell(row=current_row, column=1, value="Итого на работе")
ws.cell(row=current_row, column=2, value=len(present)) 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) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
current_row += 1
ws.column_dimensions['A'].width = 35.0 # 🚨 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список)
ws.column_dimensions['B'].width = 12.0 # Столбец A: f"{idx}. {fio} (На работе)"
# Столбец B: f"В 1С: {reason}"
# Ширина столбца B = 32.0 (ровно 230px). Автоперенос + динамическая высота строк.
anomalies = merged_df[(merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())]
ws.cell(row=current_row, column=1, value="Аномалии")
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 # Влезает символов в столбец B шириной 230px (32.0 units)
if not anomalies.empty:
for idx, (_, row) in enumerate(anomalies.iterrows(), 1):
fio = row.get('Сотрудник', '')
reason = row.get('Вид_отсутствия', 'Неизвестная причина')
reason_text = f"В 1С: {reason}"
cell_a = ws.cell(row=current_row, column=1, value=f"{idx}. {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)
# Автоперенос текста в столбце B
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center")
# Адаптивный расчет высоты строки
if len(reason_text) > chars_per_line_b:
lines_count = math.ceil(len(reason_text) / chars_per_line_b)
ws.row_dimensions[current_row].height = max(lines_count * 18, 22)
else:
ws.row_dimensions[current_row].height = 20
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True # Свернут по умолчанию
current_row += 1
# 📏 Настройка ширины колонок: B = 32.0 (ровно 230 пикселей)
ws.column_dimensions['A'].width = 45.0
ws.column_dimensions['B'].width = 32.0 # Ровно 230px в Excel
try: try:
wb.save(output_path) wb.save(output_path)
@@ -225,7 +275,7 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
for col in ws.columns: for col in ws.columns:
col_letter = get_column_letter(col[0].column) col_letter = get_column_letter(col[0].column)
if col_letter == 'G': if col_letter == 'G':
ws.column_dimensions['G'].width = 25.0 # Ровно 180 пикселей ws.column_dimensions['G'].width = 25.0
else: else:
max_len = 0 max_len = 0
for cell in col: for cell in col: