В данном скрипте Генерация документа работает:
import os
import json
import traceback
import tempfile
from datetime import datetime
from io import BytesIO
import subprocess

import pandas as pd
from PIL import Image, ImageTk
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog

try:
    import win32com.client
    WIN32COM_AVAILABLE = True
except ImportError:
    WIN32COM_AVAILABLE = False


WORKDIR = os.getcwd()
TEMPLATES_DIR = os.path.join(WORKDIR, "templates")
CONFIG_DIR = os.path.join(WORKDIR, "config")
PROFILES_DIR = os.path.join(CONFIG_DIR, "profiles")

for d in (TEMPLATES_DIR, CONFIG_DIR, PROFILES_DIR):
    os.makedirs(d, exist_ok=True)

SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.json")
PLACEHOLDERS_FILE = os.path.join(CONFIG_DIR, "placeholders.json")
MANDATORY_COLS = ['Объект', 'Свойство', 'Значение']


class Logger:
    def __init__(self):
        self.history = []

    def log(self, msg):
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        record = f"[{ts}] {msg}"
        self.history.append(record)
        print(record)
        if hasattr(app_ref, "txt_log") and app_ref.txt_log:
            app_ref.txt_log.config(state='normal')
            app_ref.txt_log.insert("end", record + "\n")
            app_ref.txt_log.see("end")
            app_ref.txt_log.config(state='disabled')

    def get_text(self):
        return "\n".join(self.history)

    def save_to_file(self, path):
        try:
            with open(path, 'w', encoding='utf-8') as f:
                f.write(self.get_text())
            self.log(f"Лог сохранён: {path}")
        except Exception as e:
            self.log(f"Ошибка сохранения лога: {e}")


logger = Logger()
app_ref = None  # Ссылка на приложение, для логирования в UI


class Settings:
    def __init__(self):
        self.data = {
            "table_style": {"header_bold": True, "alignment": "center", "font_name": "Calibri", "font_size": 11},
            "export": {"format": "docx", "pdf_password": "", "compress_images": True, "template": None}
        }
        self.load()

    def load(self):
        if os.path.exists(SETTINGS_FILE):
            try:
                with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
                    d = json.load(f)
                self.data.update(d)
                exp = self.data.get('export', {})
                if 'format' not in exp:
                    exp['format'] = 'docx'
                if 'pdf_password' not in exp:
                    exp['pdf_password'] = ''
                if 'compress_images' not in exp:
                    exp['compress_images'] = True
                self.data['export'] = exp
                logger.log("Настройки загружены")
            except Exception as e:
                logger.log(f"Ошибка загрузки настроек: {e}")

    def save(self):
        try:
            with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            logger.log("Настройки сохранены")
        except Exception as e:
            logger.log(f"Ошибка сохранения настроек: {e}")

    def save_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            logger.log(f"Профиль сохранён: {filename}")
            return True
        except Exception as e:
            logger.log(f"Ошибка сохранения профиля: {e}")
            return False

    def load_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'r', encoding='utf-8') as f:
                d = json.load(f)
            self.data.update(d)
            logger.log(f"Профиль загружен: {filename}")
            return True
        except Exception as e:
            logger.log(f"Ошибка загрузки профиля: {e}")
            return False


settings = Settings()


class Placeholders:
    def __init__(self):
        self.placeholders = {}
        self.load()

    def load(self):
        if os.path.isfile(PLACEHOLDERS_FILE):
            try:
                with open(PLACEHOLDERS_FILE, 'r', encoding='utf-8') as f:
                    self.placeholders = json.load(f)
                logger.log("Плейсхолдеры загружены")
            except Exception as e:
                logger.log(f"Ошибка загрузки плейсхолдеров: {e}")

    def save(self):
        try:
            with open(PLACEHOLDERS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.placeholders, f, ensure_ascii=False, indent=4)
            logger.log("Плейсхолдеры сохранены")
        except Exception as e:
            logger.log(f"Ошибка сохранения плейсхолдеров: {e}")


placeholders = Placeholders()


def extract_article_full(obj):
    if not isinstance(obj, str) or not obj.strip():
        return ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        return obj[idx + 4:].strip()
    words = obj.strip().split()
    if len(words) <= 2:
        return obj.strip()
    return " ".join(words[-3:]).strip()


def extract_product_type(obj):
    if not isinstance(obj, str) or not obj.strip():
        return ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        part = obj[:idx].strip()
        if part:
            return part
        return "Неизвестно"
    words = obj.strip().split()
    if len(words) <= 3:
        return " ".join(words)
    return " ".join(words[:3])


def extract_param(prop):
    if not isinstance(prop, str):
        return ""
    return prop.split("_")[-1].strip()


def load_and_transform_excel_grouped(path):
    logger.log(f"Загрузка файла: {path}")
    df = pd.read_excel(path, engine="openpyxl")
    missing = [c for c in MANDATORY_COLS if c not in df.columns]
    if missing:
        raise ValueError(f"Отсутствуют колонки: {missing}")

    df["Тип товара"] = df["Объект"].apply(extract_product_type)
    df["Артикул"] = df["Объект"].apply(extract_article_full)
    df["Параметр"] = df["Свойство"].apply(extract_param)
    df["Значение"] = df["Значение"].astype(str)
    df = df[(df["Артикул"] != "") & (df["Параметр"] != "")].reset_index(drop=True)

    grouped = {}
    for art, gdf in df.groupby("Артикул"):
        obj_full = gdf["Объект"].iloc[0]
        prod_type = gdf["Тип товара"].iloc[0]

        sections = []
        current_header = None
        current_params = []

        for _, row in gdf.iterrows():
            if row["Значение"].strip().lower() == "<раздел>":
                if current_header is not None or current_params:
                    sections.append({"header": current_header, "params": current_params})
                current_header = row["Параметр"]
                current_params = []
            else:
                current_params.append({"parameter": row["Параметр"], "value": row["Значение"]})

        if current_header is not None or current_params:
            sections.append({"header": current_header, "params": current_params})

        if not sections:
            sections = [
                {
                    "header": None,
                    "params": [{"parameter": r["Параметр"], "value": r["Значение"]} for _, r in gdf.iterrows()],
                }
            ]

        grouped[art] = {"object_full": obj_full, "product_type": prod_type, "sections": sections}
    logger.log(f"Данные сгруппированы по артикулам: {len(grouped)}")
    return grouped


def inline_replace_in_paragraph(paragraph, placeholder, replacement):
    runs = paragraph.runs
    full_text = "".join(run.text for run in runs)
    if placeholder not in full_text:
        return
    new_text = full_text.replace(placeholder, replacement)
    for run in runs:
        run.text = ""
    if runs:
        runs[0].text = new_text


def docx_replace_placeholder(doc, key, value):
    ph = "{{" + key + "}}"
    for p in doc.paragraphs:
        if ph in p.text:
            inline_replace_in_paragraph(p, ph, str(value))
    for tbl in doc.tables:
        for row in tbl.rows:
            for cell in row.cells:
                for p in cell.paragraphs:
                    if ph in p.text:
                        inline_replace_in_paragraph(p, ph, str(value))


def add_section_table(doc, section, style):
    if section["header"]:
        p = doc.add_paragraph()
        run = p.add_run(section["header"])
        run.bold = True
        run.font.size = Pt(style.get("font_size", 12))
        p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    tbl = doc.add_table(rows=1, cols=2)
    # Убираем назначение стиля таблицы, чтобы избежать ошибки
    # tbl.style = "Table Grid"

    hdr_cells = tbl.rows[0].cells
    hdr_cells[0].text = "Параметр"
    hdr_cells[1].text = "Значение"
    for c in hdr_cells:
        p_cell = c.paragraphs[0]
        for r in p_cell.runs:
            r.bold = style.get("header_bold", True)
            r.font.name = style.get("font_name", "Calibri")
            r.font.size = Pt(style.get("font_size", 11))
        alignment = style.get("alignment", "center")
        p_cell.alignment = {
            "left": WD_ALIGN_PARAGRAPH.LEFT,
            "right": WD_ALIGN_PARAGRAPH.RIGHT,
            "center": WD_ALIGN_PARAGRAPH.CENTER,
        }[alignment]
    for param in section["params"]:
        row_cells = tbl.add_row().cells
        row_cells[0].text = str(param["parameter"])
        row_cells[1].text = str(param["value"])
        for i in (0, 1):
            p_cell = row_cells[i].paragraphs[0]
            for r in p_cell.runs:
                r.font.name = style.get("font_name", "Calibri")
                r.font.size = Pt(style.get("font_size", 11))
            p_cell.alignment = {
                "left": WD_ALIGN_PARAGRAPH.LEFT,
                "right": WD_ALIGN_PARAGRAPH.RIGHT,
                "center": WD_ALIGN_PARAGRAPH.CENTER,
            }[style.get("alignment", "center")]


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        global app_ref
        app_ref = self

        self.title("Автоматизация техдокументации")
        self.geometry("1350x820")

        self.grouped_data = {}
        self.filtered_df = pd.DataFrame()
        self.filtered_df_backup = pd.DataFrame()
        self.basket_df = pd.DataFrame(
            columns=["Объект", "Тип товара", "Артикул", "Параметр", "Значение"]
        )

        self.user_placeholders = placeholders.placeholders.copy()
        self.templates = []
        self.current_template = None

        self.selected_profile = None

        self.create_widgets()
        self.create_menu()
        self.bind_shortcuts()
        self.load_templates()
        self.load_profiles()
        self.load_placeholders_to_tree()
        self.load_settings_to_gui()

        logger.log("Приложение запущено")

    def create_widgets(self):
        self.tabs = ttk.Notebook(self)
        self.tabs.pack(fill="both", expand=True)

        # Вкладка "Данные"
        tab_data = ttk.Frame(self.tabs)
        self.tabs.add(tab_data, text="Данные")

        fsearch = ttk.Frame(tab_data)
        fsearch.pack(fill="x", padx=5, pady=3)
        ttk.Label(fsearch, text="Поиск по артикулу:").pack(side="left")
        self.var_quick_search = tk.StringVar()
        ent_qs = ttk.Entry(fsearch, textvariable=self.var_quick_search)
        ent_qs.pack(side="left", fill="x", expand=True, padx=5)
        ent_qs.bind("<KeyRelease>", self.quick_search)

        ffilter = ttk.Frame(tab_data)
        ffilter.pack(fill="x", padx=5, pady=3)
        ttk.Label(ffilter, text="Фильтр по колонке:").pack(side="left")
        self.combo_filter_col = ttk.Combobox(ffilter, state="readonly", width=20)
        self.combo_filter_col.pack(side="left", padx=5)
        ttk.Label(ffilter, text="Оператор:").pack(side="left")
        self.combo_filter_op = ttk.Combobox(
            ffilter,
            state="readonly",
            values=["содержит", "равно", "начинается с", "оканчивается на"],
            width=15,
        )
        self.combo_filter_op.set("содержит")
        self.combo_filter_op.pack(side="left", padx=5)
        self.var_filter_val = tk.StringVar()
        ent_fv = ttk.Entry(ffilter, textvariable=self.var_filter_val)
        ent_fv.pack(side="left", fill="x", expand=True, padx=5)
        ent_fv.bind("<KeyRelease>", self.advanced_filter)
        self.combo_filter_op.bind("<<ComboboxSelected>>", lambda e: self.advanced_filter())
        self.combo_filter_col.bind("<<ComboboxSelected>>", lambda e: self.advanced_filter())
        ttk.Button(ffilter, text="Очистить", command=self.clear_filter).pack(side="left", padx=5)

        tbl_frame = ttk.Frame(tab_data)
        tbl_frame.pack(fill="both", expand=True, padx=5, pady=5)
        self.tree_data = ttk.Treeview(tbl_frame, show="headings", selectmode="extended")
        self.tree_data.pack(side="left", fill="both", expand=True)
        self.tree_data.bind("<Double-1>", self.add_selected_to_basket)
        self.tree_data.bind("<Button-3>", self.context_menu_data)

        scr_y = ttk.Scrollbar(tbl_frame, orient="vertical", command=self.tree_data.yview)
        scr_y.pack(side="left", fill="y")
        scr_x = ttk.Scrollbar(tbl_frame, orient="horizontal", command=self.tree_data.xview)
        scr_x.pack(side="bottom", fill="x")
        self.tree_data.configure(yscrollcommand=scr_y.set, xscrollcommand=scr_x.set)

        btn_frame = ttk.Frame(tab_data)
        btn_frame.pack(fill="x", padx=5, pady=5)
        ttk.Button(btn_frame, text="Загрузить Excel", command=self.load_excel_dialog).pack(side="left")
        ttk.Button(btn_frame, text="Добавить в корзину", command=self.add_selected_to_basket).pack(side="left", padx=5)

        # Вкладка "Корзина"
        tab_basket = ttk.Frame(self.tabs)
        self.tabs.add(tab_basket, text="Корзина")

        basket_frame = ttk.Frame(tab_basket)
        basket_frame.pack(fill="both", expand=True, padx=5, pady=5)
        self.tree_basket = ttk.Treeview(basket_frame, show="headings", selectmode="extended")
        self.tree_basket.pack(side="left", fill="both", expand=True)
        self.tree_basket.bind("<Delete>", lambda e: self.remove_selected_from_basket())
        self.tree_basket.bind("<Button-3>", self.context_menu_basket)

        sb_basket_y = ttk.Scrollbar(basket_frame, orient="vertical", command=self.tree_basket.yview)
        sb_basket_y.pack(side="left", fill="y")
        sb_basket_x = ttk.Scrollbar(basket_frame, orient="horizontal", command=self.tree_basket.xview)
        sb_basket_x.pack(side="bottom", fill="x")
        self.tree_basket.configure(yscrollcommand=sb_basket_y.set, xscrollcommand=sb_basket_x.set)

        ctrl_basket = ttk.Frame(tab_basket)
        ctrl_basket.pack(fill="x", padx=5, pady=5)
        ttk.Button(ctrl_basket, text="Удалить выделенные", command=self.remove_selected_from_basket).pack(side="left", padx=5)
        ttk.Button(ctrl_basket, text="Очистить корзину", command=self.clear_basket).pack(side="left", padx=5)
        ttk.Button(ctrl_basket, text="Показать текст корзины", command=self.show_basket_text).pack(side="left", padx=5)

        self.txt_basket_text = tk.Text(tab_basket, height=10)
        self.txt_basket_text.pack(fill="both", padx=5, pady=5)

        # Вкладка "Настройки и Генерация"
        tab_set_gen = ttk.Frame(self.tabs)
        self.tabs.add(tab_set_gen, text="Настройки и Генерация")

        prof_frame = ttk.LabelFrame(tab_set_gen, text="Профили настроек")
        prof_frame.pack(fill='x', padx=10, pady=5)
        self.combo_profiles = ttk.Combobox(prof_frame, state='readonly')
        self.combo_profiles.pack(side='left', fill='x', expand=True, padx=5, pady=5)
        self.combo_profiles.bind("<<ComboboxSelected>>", self.on_profile_change)
        ttk.Button(prof_frame, text="Загрузить профиль", command=self.load_profile).pack(side='left', padx=5)
        ttk.Button(prof_frame, text="Сохранить профиль", command=self.save_profile).pack(side='left', padx=5)
        ttk.Button(prof_frame, text="Обновить список", command=self.load_profiles).pack(side='left', padx=5)

        ttk.Label(tab_set_gen, text="Выбор шаблона:").pack(anchor='w', padx=10)
        self.combo_templates = ttk.Combobox(tab_set_gen, state='readonly', width=70)
        self.combo_templates.pack(fill='x', padx=10, pady=5)
        self.combo_templates.bind("<<ComboboxSelected>>", self.on_template_change)
        ttk.Button(tab_set_gen, text="Предпросмотр шаблона", command=self.preview_template).pack(anchor='w', padx=10)

        ttk.Label(tab_set_gen, text="Плейсхолдеры:").pack(anchor='w', padx=10, pady=3)
        self.tree_placeholders = ttk.Treeview(tab_set_gen, columns=("name", "value"), show='headings', height=10)
        self.tree_placeholders.pack(fill='both', padx=10, pady=3, expand=True)
        self.tree_placeholders.heading("name", text="Имя")
        self.tree_placeholders.heading("value", text="Значение")
        self.tree_placeholders.column("name", width=200)
        self.tree_placeholders.column("value", width=400)

        ph_btn_frame = ttk.Frame(tab_set_gen)
        ph_btn_frame.pack(fill='x', padx=10, pady=1)
        ttk.Button(ph_btn_frame, text="Добавить", command=self.add_placeholder).pack(side='left', padx=5)
        ttk.Button(ph_btn_frame, text="Редактировать", command=self.edit_placeholder).pack(side='left', padx=5)
        ttk.Button(ph_btn_frame, text="Удалить", command=self.delete_placeholder).pack(side='left', padx=5)
        ttk.Button(ph_btn_frame, text="Импорт из JSON", command=self.import_placeholders).pack(side='left', padx=5)
        ttk.Button(ph_btn_frame, text="Экспорт в JSON", command=self.export_placeholders).pack(side='left', padx=5)

        style_label = ttk.Label(tab_set_gen, text="Параметры таблицы:")
        style_label.pack(anchor='w', padx=10, pady=(10, 3))
        style_frm = ttk.Frame(tab_set_gen)
        style_frm.pack(fill='x', padx=10, pady=5)
        self.var_header_bold = tk.BooleanVar(value=settings.data['table_style']['header_bold'])
        ttk.Checkbutton(style_frm, text="Жирный заголовок", variable=self.var_header_bold).grid(row=0, column=0, padx=5, pady=2)
        ttk.Label(style_frm, text="Выравнивание:").grid(row=0, column=1, padx=5, pady=2, sticky='w')
        self.combo_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_align.set(settings.data['table_style']['alignment'])
        self.combo_align.grid(row=0, column=2, padx=5, pady=2)
        ttk.Label(style_frm, text="Шрифт:").grid(row=0, column=3, padx=5, pady=2, sticky='w')
        self.entry_font_name = ttk.Entry(style_frm, width=15)
        self.entry_font_name.insert(0, settings.data['table_style']['font_name'])
        self.entry_font_name.grid(row=0, column=4, padx=5, pady=2)
        ttk.Label(style_frm, text="Размер:").grid(row=0, column=5, padx=5, pady=2, sticky='w')
        self.spin_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_font_size.delete(0, 'end')
        self.spin_font_size.insert(0, settings.data['table_style']['font_size'])
        self.spin_font_size.grid(row=0, column=6, padx=5, pady=2)

        export_label = ttk.Label(tab_set_gen, text="Настройки экспорта:")
        export_label.pack(anchor='w', padx=10, pady=(10, 3))
        export_frm = ttk.Frame(tab_set_gen)
        export_frm.pack(fill='x', padx=10, pady=5)
        ttk.Label(export_frm, text="Формат файла:").pack(side='left', padx=5)
        self.combo_export_format = ttk.Combobox(export_frm, values=['docx', 'pdf'], state='readonly', width=10)
        self.combo_export_format.set(settings.data['export']['format'])
        self.combo_export_format.pack(side='left', padx=5)
        self.var_compress_images = tk.BooleanVar(value=settings.data['export']['compress_images'])
        ttk.Checkbutton(export_frm, text="Сжимать изображения", variable=self.var_compress_images).pack(side='left', padx=10)
        ttk.Label(export_frm, text="Пароль для PDF:").pack(side='left', padx=5)
        self.entry_pdf_password = ttk.Entry(export_frm, width=20, show='*')
        self.entry_pdf_password.insert(0, settings.data['export']['pdf_password'])
        self.entry_pdf_password.pack(side='left', padx=5)

        ttk.Button(tab_set_gen, text="Сохранить настройки в профиль", command=self.save_profile).pack(padx=10, pady=10, anchor='e')
        ttk.Button(tab_set_gen, text="Предпросмотр итогового документа", command=self.preview_generated_document).pack(padx=10, pady=5, anchor='e')
        ttk.Button(tab_set_gen, text="Генерировать документ", command=self.generate_document).pack(padx=10, pady=5, anchor='e')

        # Вкладка "Лог"
        tab_log = ttk.Frame(self.tabs)
        self.tabs.add(tab_log, text="Лог")
        self.txt_log = tk.Text(tab_log, state='disabled', wrap='word')
        self.txt_log.pack(fill='both', expand=True, padx=5, pady=5)
        ttk.Button(tab_log, text="Экспорт лога", command=self.export_log).pack(pady=5)

    def create_menu(self):
        menubar = tk.Menu(self)
        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="Открыть Excel (Ctrl+O)", command=self.load_excel_dialog)
        file_menu.add_command(label="Генерировать документ (Ctrl+S)", command=self.generate_document)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.quit)
        menubar.add_cascade(label="Файл", menu=file_menu)
        self.config(menu=menubar)

    def bind_shortcuts(self):
        self.bind("<Control-o>", lambda e: self.load_excel_dialog())
        self.bind("<Control-s>", lambda e: self.generate_document())

    def load_excel_dialog(self):
        path = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel Files", "*.xlsx *.xls")])
        if not path:
            return
        try:
            self.grouped_data = load_and_transform_excel_grouped(path)
            rows = []
            for art, info in self.grouped_data.items():
                obj = info["object_full"]
                ptype = info["product_type"]
                for section in info["sections"]:
                    if section["header"]:
                        rows.append(
                            {
                                "Объект": obj,
                                "Тип товара": ptype,
                                "Артикул": art,
                                "Параметр": f"Раздел: {section['header']}",
                                "Значение": "",
                            }
                        )
                    for param in section["params"]:
                        rows.append(
                            {
                                "Объект": obj,
                                "Тип товара": ptype,
                                "Артикул": art,
                                "Параметр": param["parameter"],
                                "Значение": param["value"],
                            }
                        )
            self.filtered_df = pd.DataFrame(rows)
            self.filtered_df_backup = self.filtered_df.copy()
            self.update_data_table()
            self.combo_filter_col["values"] = list(self.filtered_df.columns)
            if not self.combo_filter_col.get():
                self.combo_filter_col.set(self.filtered_df.columns[0])
            self.basket_df = pd.DataFrame(columns=self.filtered_df.columns)
            self.update_basket_table()
            messagebox.showinfo("Успех", f"Данные загружены: {len(self.filtered_df)} записей")
            logger.log(f"Файл загружен: {path}, записей: {len(self.filtered_df)}")
        except Exception as e:
            traceback.print_exc()
            messagebox.showerror("Ошибка загрузки", str(e))
            logger.log(f"Ошибка загрузки: {e}")

    def update_data_table(self):
        self.tree_data.delete(*self.tree_data.get_children())
        if self.filtered_df.empty:
            self.tree_data["columns"] = []
            return
        self.tree_data["columns"] = list(self.filtered_df.columns)
        for col in self.filtered_df.columns:
            self.tree_data.heading(col, text=col)
            self.tree_data.column(col, width=160, anchor="w")
        for _, row in self.filtered_df.iterrows():
            self.tree_data.insert("", "end", values=list(row))

    def quick_search(self, event=None):
        if self.filtered_df_backup.empty:
            return
        query = self.var_quick_search.get().lower().strip()
        if not query:
            self.filtered_df = self.filtered_df_backup.copy()
        else:
            self.filtered_df = self.filtered_df_backup[
                self.filtered_df_backup['Артикул'].str.lower().str.contains(query, na=False)
            ]
        self.update_data_table()

    def advanced_filter(self, event=None):
        if self.filtered_df_backup.empty:
            return
        col = self.combo_filter_col.get()
        op = self.combo_filter_op.get()
        val = self.var_filter_val.get().lower().strip()
        if not col or not val:
            self.filtered_df = self.filtered_df_backup.copy()
            self.update_data_table()
            return
        series = self.filtered_df_backup[col].astype(str).str.lower()
        mask = None
        if op == "содержит":
            mask = series.str.contains(val, na=False)
        elif op == "равно":
            mask = series == val
        elif op == "начинается с":
            mask = series.str.startswith(val, na=False)
        elif op == "оканчивается на":
            mask = series.str.endswith(val, na=False)
        if mask is not None:
            self.filtered_df = self.filtered_df_backup[mask]
            self.update_data_table()

    def clear_filter(self):
        self.var_quick_search.set("")
        self.var_filter_val.set("")
        if hasattr(self, 'filtered_df_backup'):
            self.filtered_df = self.filtered_df_backup.copy()
            self.update_data_table()

    def add_selected_to_basket(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            messagebox.showinfo("Добавление", "Выделите записи для добавления в корзину")
            return
        rows = [self.tree_data.item(iid)["values"] for iid in sel]
        df_add = pd.DataFrame(rows, columns=self.filtered_df.columns)
        self.basket_df = pd.concat([self.basket_df, df_add], ignore_index=True)
        self.update_basket_table()
        logger.log(f"В корзину добавлено: {len(rows)} записей")

    def update_basket_table(self):
        self.tree_basket.delete(*self.tree_basket.get_children())
        if self.basket_df.empty:
            self.tree_basket["columns"] = []
            return
        self.tree_basket["columns"] = list(self.basket_df.columns)
        for col in self.basket_df.columns:
            self.tree_basket.heading(col, text=col)
            self.tree_basket.column(col, width=160, anchor="w")
        for _, row in self.basket_df.iterrows():
            self.tree_basket.insert("", "end", values=list(row))

    def remove_selected_from_basket(self):
        sel = self.tree_basket.selection()
        if not sel:
            messagebox.showinfo("Удаление", "Выделите записи для удаления из корзины")
            return
        idxs = [self.tree_basket.index(iid) for iid in sel]
        self.basket_df.drop(self.basket_df.index[idxs], inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        logger.log(f"Удалено из корзины: {len(idxs)} записей")

    def clear_basket(self):
        if messagebox.askyesno("Очистка корзины", "Очистить корзину?"):
            self.basket_df = pd.DataFrame(columns=self.filtered_df.columns)
            self.update_basket_table()
            logger.log("Корзина очищена")

    def show_basket_text(self):
        self.txt_basket_text.delete("1.0", "end")
        if self.basket_df.empty:
            self.txt_basket_text.insert("end", "(Корзина пуста)")
        else:
            self.txt_basket_text.insert("end", self.basket_df.to_string(index=False))

    def context_menu_data(self, event):
        iid = self.tree_data.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Добавить в корзину", command=self.add_selected_to_basket)
        menu.post(event.x_root, event.y_root)

    def context_menu_basket(self, event):
        iid = self.tree_basket.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Удалить из корзины", command=self.remove_selected_from_basket)
        menu.post(event.x_root, event.y_root)

    def preview_template(self):
        if not self.current_template:
            messagebox.showwarning("Предпросмотр", "Шаблон не выбран")
            return
        path = os.path.join(TEMPLATES_DIR, self.current_template)
        try:
            if os.name == 'nt':
                os.startfile(path)
            else:
                try:
                    subprocess.Popen(["open", path])
                except:
                    subprocess.Popen(["xdg-open", path])
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка предпросмотра шаблона:\n{e}")

    def preview_generated_document(self):
        if self.basket_df.empty:
            messagebox.showwarning("Предпросмотр", "Корзина пуста")
            return
        if not self.current_template:
            messagebox.showwarning("Предпросмотр", "Шаблон не выбран")
            return
        try:
            with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as tmpf:
                tmpf.close()
                self.generate_document(path=tmpf.name, show_messages=False)
                if os.name == 'nt':
                    os.startfile(tmpf.name)
                else:
                    try:
                        subprocess.Popen(["open", tmpf.name])
                    except:
                        subprocess.Popen(["xdg-open", tmpf.name])
        except Exception as e:
            messagebox.showerror("Ошибка предпросмотра", str(e))

    def save_profile(self):
        try:
            self.save_gui_to_settings()
            if not self.selected_profile:
                prof_name = simpledialog.askstring("Сохранить профиль", "Имя профиля (без расширения):", parent=self)
                if not prof_name:
                    messagebox.showinfo("Отмена", "Сохранение отменено")
                    return
                fname = prof_name.strip() + ".json"
            else:
                fname = self.selected_profile
            if settings.save_profile(fname):
                self.selected_profile = fname
                self.load_profiles()
                self.combo_profiles.set(fname)
                messagebox.showinfo("Успех", f"Профиль {fname} сохранён")
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка сохранения профиля:\n{e}")

    def load_profile(self):
        prof = self.combo_profiles.get()
        if not prof:
            return
        if settings.load_profile(prof):
            self.selected_profile = prof
            self.load_settings_to_gui()
            messagebox.showinfo("Успех", f"Профиль {prof} загружен")

    def load_profiles(self):
        if not os.path.exists(PROFILES_DIR):
            os.makedirs(PROFILES_DIR)
        files = sorted([f for f in os.listdir(PROFILES_DIR) if f.endswith('.json')])
        self.combo_profiles['values'] = files
        if files and not self.selected_profile:
            self.combo_profiles.set(files[0])
            self.selected_profile = files[0]

    def on_profile_change(self, event=None):
        selected = self.combo_profiles.get()
        if selected:
            self.selected_profile = selected
            if settings.load_profile(selected):
                self.load_settings_to_gui()
                messagebox.showinfo("Профиль", f"Профиль '{selected}' загружен")
            else:
                messagebox.showerror("Ошибка", f"Ошибка загрузки профиля: {selected}")

    def on_template_change(self, event=None):
        selected = self.combo_templates.get()
        if selected:
            self.current_template = selected
            logger.log(f"Выбран шаблон: {selected}")

    def load_placeholders_to_tree(self):
        self.user_placeholders = placeholders.placeholders.copy()
        self.refresh_placeholders_view()

    def refresh_placeholders_view(self):
        self.tree_placeholders.delete(*self.tree_placeholders.get_children())
        for k, v in self.user_placeholders.items():
            self.tree_placeholders.insert("", "end", values=(k, v))

    def add_placeholder(self):
        name = simpledialog.askstring("Добавить плейсхолдер", "Имя плейсхолдера:", parent=self)
        if not name:
            return
        if name in self.user_placeholders:
            messagebox.showerror("Ошибка", "Такой плейсхолдер уже существует")
            return
        value = simpledialog.askstring("Добавить плейсхолдер", "Значение плейсхолдера:", parent=self)
        if value is None:
            return
        self.user_placeholders[name] = value
        placeholders.placeholders = self.user_placeholders.copy()
        placeholders.save()
        self.refresh_placeholders_view()
        logger.log(f"Добавлен плейсхолдер: {name} = {value}")

    def edit_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Редактировать", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name, old_value = self.tree_placeholders.item(iid, "values")
        new_value = simpledialog.askstring("Редактировать плейсхолдер", f"Значение плейсхолдера '{name}':", initialvalue=old_value, parent=self)
        if new_value is None:
            return
        self.user_placeholders[name] = new_value
        placeholders.placeholders = self.user_placeholders.copy()
        placeholders.save()
        self.refresh_placeholders_view()
        logger.log(f"Изменён плейсхолдер: {name} = {new_value}")

    def delete_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Удалить", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name = self.tree_placeholders.item(iid, "values")[0]
        if messagebox.askyesno("Удалить плейсхолдер", f"Удалить плейсхолдер '{name}'?"):
            self.user_placeholders.pop(name, None)
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            logger.log(f"Удалён плейсхолдер: {name}")

    def import_placeholders(self):
        path = filedialog.askopenfilename(title="Импорт плейсхолдеров из JSON", filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'r', encoding='utf-8') as f:
                d = json.load(f)
            if not isinstance(d, dict):
                raise ValueError("Файл должен содержать словарь плейсхолдеров")
            self.user_placeholders.update(d)
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            messagebox.showinfo("Импорт", f"Импортировано {len(d)} плейсхолдеров")
            logger.log(f"Импортировано плейсхолдеров: {len(d)}")
        except Exception as e:
            messagebox.showerror("Ошибка импорта", str(e))
            logger.log(f"Ошибка импорта плейсхолдеров: {e}")

    def export_placeholders(self):
        path = filedialog.asksaveasfilename(title="Экспорт плейсхолдеров в JSON", defaultextension=".json",
                                            filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'w', encoding='utf-8') as f:
                json.dump(self.user_placeholders, f, ensure_ascii=False, indent=4)
            messagebox.showinfo("Экспорт", f"Плейсхолдеры экспортированы: {path}")
            logger.log(f"Плейсхолдеры экспортированы: {path}")
        except Exception as e:
            messagebox.showerror("Ошибка экспорта", str(e))
            logger.log(f"Ошибка экспорта плейсхолдеров: {e}")

    def export_log(self):
        path = filedialog.asksaveasfilename(title="Сохранить лог", defaultextension=".txt",
                                            filetypes=[("Text files", "*.txt")])
        if not path:
            return
        logger.save_to_file(path)
        messagebox.showinfo("Лог сохранён", f"Лог сохранён в файл:\n{path}")

    def save_gui_to_settings(self):
        d = settings.data
        exp = d.setdefault("export", {})
        exp["format"] = self.combo_export_format.get() if hasattr(self, "combo_export_format") else "docx"
        exp["compress_images"] = self.var_compress_images.get() if hasattr(self, "var_compress_images") else True
        exp["pdf_password"] = self.entry_pdf_password.get() if hasattr(self, "entry_pdf_password") else ""
        exp["template"] = self.combo_templates.get() if hasattr(self, "combo_templates") else None

        ts = d.setdefault("table_style", {})
        ts["header_bold"] = self.var_header_bold.get() if hasattr(self, "var_header_bold") else True
        ts["alignment"] = self.combo_align.get() if hasattr(self, "combo_align") else "center"
        ts["font_name"] = self.entry_font_name.get() if hasattr(self, "entry_font_name") else "Calibri"
        try:
            ts["font_size"] = int(self.spin_font_size.get()) if hasattr(self, "spin_font_size") else 11
        except:
            ts["font_size"] = 11

        settings.save()

    def load_settings_to_gui(self):
        d = settings.data
        ex = d.get("export", {})
        ts = d.get("table_style", {})

        if hasattr(self, "combo_export_format"):
            self.combo_export_format.set(ex.get("format", "docx"))
        if hasattr(self, "var_compress_images"):
            self.var_compress_images.set(ex.get("compress_images", True))
        if hasattr(self, "entry_pdf_password"):
            self.entry_pdf_password.delete(0, "end")
            self.entry_pdf_password.insert(0, ex.get("pdf_password", ""))

        if hasattr(self, "var_header_bold"):
            self.var_header_bold.set(ts.get("header_bold", True))
        if hasattr(self, "combo_align"):
            self.combo_align.set(ts.get("alignment", "center"))
        if hasattr(self, "entry_font_name"):
            self.entry_font_name.delete(0, "end")
            self.entry_font_name.insert(0, ts.get("font_name", "Calibri"))
        if hasattr(self, "spin_font_size"):
            self.spin_font_size.delete(0, "end")
            self.spin_font_size.insert(0, ts.get("font_size", 11))

        if hasattr(self, "combo_templates"):
            self.load_templates()
            tmpl = ex.get("template")
            if self.templates:
                if tmpl in self.templates:
                    self.combo_templates.set(tmpl)
                    self.current_template = tmpl
                else:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def load_templates(self):
        self.templates = sorted([f for f in os.listdir(TEMPLATES_DIR) if f.lower().endswith(".docx")])
        if hasattr(self, "combo_templates"):
            self.combo_templates["values"] = self.templates
            if self.templates:
                if self.current_template not in self.templates:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def generate_document(self, path=None, show_messages=True):
        if self.basket_df.empty:
            if show_messages:
                messagebox.showwarning("Генерация", "Корзина пуста")
            return
        if not self.current_template:
            if show_messages:
                messagebox.showwarning("Генерация", "Шаблон не выбран")
            return

        self.save_gui_to_settings()

        if not path:
            path = filedialog.asksaveasfilename(title="Сохранить документ",
                                                defaultextension=".docx",
                                                filetypes=[("Word Document", "*.docx")])
            if not path:
                return

        try:
            template_path = os.path.join(TEMPLATES_DIR, self.current_template)
            doc = Document(template_path)

            # Собираем плейсхолдеры из пользовательских
            phs = self.user_placeholders.copy()

            # Уникальные артикула в корзине
            unique_arts = list(dict.fromkeys(self.basket_df["Артикул"]))

            art_params = {}
            art_info = {}

            # Формируем словари данных для каждого артикула
            for art in unique_arts:
                sub_df = self.basket_df[self.basket_df["Артикул"] == art]
                if not sub_df.empty:
                    art_info[art] = {
                        "Объект": sub_df.iloc[0]["Объект"],
                        "Тип товара": sub_df.iloc[0]["Тип товара"],
                        "Артикул": art,
                    }
                else:
                    art_info[art] = {"Объект": "", "Тип товара": "", "Артикул": art}
                art_params[art] = []
                for _, r in sub_df.iterrows():
                    if str(r["Параметр"]).startswith("Раздел:"):
                        continue
                    art_params[art].append({"parameter": r["Параметр"], "value": r["Значение"]})

            # Замена основных базовых плейсхолдеров из первого артикула
            if unique_arts:
                first_art = unique_arts[0]
                for key in ["Тип товара", "Артикул", "Объект"]:
                    val = art_info[first_art].get(key, "")
                    phs[key] = val

            # Основная замена пользовательских плейсхолдеров
            for k, v in phs.items():
                docx_replace_placeholder(doc, k, v)

            # Ищем параграфы с {{Таблица}} и заменяем их на таблицы параметров по каждому артикулу
            replaced_table_placeholders = 0
            paras = list(doc.paragraphs)
            for i, p in enumerate(paras):
                if "{{Таблица}}" in p.text:
                    replaced_table_placeholders += 1
                    p._element.getparent().remove(p._element)
                    for art in unique_arts:
                        p1 = doc.add_paragraph()
                        run = p1.add_run(f"Артикул: {art_info[art].get('Артикул', '')}")
                        run.bold = True
                        run.font.size = Pt(14)
                        p1.alignment = WD_ALIGN_PARAGRAPH.LEFT

                        p2 = doc.add_paragraph()
                        run2 = p2.add_run(f"Объект: {art_info[art].get('Объект', '')}")
                        run2.font.size = Pt(12)
                        p2.alignment = WD_ALIGN_PARAGRAPH.LEFT

                        p3 = doc.add_paragraph()
                        run3 = p3.add_run(f"Тип товара: {art_info[art].get('Тип товара', '')}")
                        run3.font.size = Pt(12)
                        p3.alignment = WD_ALIGN_PARAGRAPH.LEFT

                        add_section_table(doc, {"header": None, "params": art_params[art]}, settings.data.get("table_style", {}))
                        doc.add_paragraph()

            if replaced_table_placeholders == 0:
                logger.log("Плейсхолдер {{Таблица}} не найден в шаблоне")

            doc.save(path)
            logger.log(f"Документ сгенерирован и сохранён: {path}")

            if settings.data.get("export", {}).get("format") == "pdf":
                pdf_path = os.path.splitext(path)[0] + ".pdf"
                if WIN32COM_AVAILABLE:
                    convert_docx_to_pdf(path, pdf_path)
                    if show_messages:
                        messagebox.showinfo("Успех", f"Документ сгенерирован и сохранён:\n{path}\nФормат PDF:\n{pdf_path}")
                else:
                    logger.log("Для создания PDF необходим MS Word и pywin32")
                    messagebox.showwarning("PDF", "Конвертация в PDF невозможна. Не найден пакет pywin32.")
            else:
                if show_messages:
                    messagebox.showinfo("Успех", f"Документ сгенерирован и сохранён:\n{path}")

        except Exception as e:
            traceback.print_exc()
            logger.log(f"Ошибка генерации документа: {e}")
            if show_messages:
                messagebox.showerror("Ошибка генерации", str(e))


def main():
    app = App()
    app.mainloop()


if __name__ == '__main__':
    main()

а в этом не работает:
import os
import json
import traceback
import tempfile
from datetime import datetime
from io import BytesIO
import subprocess

import pandas as pd
from PIL import Image, ImageTk
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog

try:
    import win32com.client
    WIN32COM_AVAILABLE = True
except ImportError:
    WIN32COM_AVAILABLE = False

WORKDIR = os.getcwd()
TEMPLATES_DIR = os.path.join(WORKDIR, "templates")
CONFIG_DIR = os.path.join(WORKDIR, "config")
PROFILES_DIR = os.path.join(CONFIG_DIR, "profiles")

for d in (TEMPLATES_DIR, CONFIG_DIR, PROFILES_DIR):
    os.makedirs(d, exist_ok=True)

SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.json")
PLACEHOLDERS_FILE = os.path.join(CONFIG_DIR, "placeholders.json")
MANDATORY_COLS = ['Объект', 'Свойство', 'Значение']

class Logger:
    def __init__(self):
        self.history = []

    def log(self, msg):
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        record = f"[{ts}] {msg}"
        self.history.append(record)
        print(record)
        if hasattr(app_ref, "txt_log") and app_ref.txt_log:
            app_ref.txt_log.config(state='normal')
            app_ref.txt_log.insert("end", record + "\n")
            app_ref.txt_log.see("end")
            app_ref.txt_log.config(state='disabled')

    def get_text(self):
        return "\n".join(self.history)

    def save_to_file(self, path):
        try:
            with open(path, 'w', encoding='utf-8') as f:
                f.write(self.get_text())
            self.log(f"Лог сохранён: {path}")
        except Exception as e:
            self.log(f"Ошибка сохранения лога: {e}")

logger = Logger()
app_ref = None  # Ссылка на приложение, для логирования в UI

class Settings:
    def __init__(self):
        self.data = {
            "table_style": {"header_bold": True, "alignment": "center", "font_name": "Calibri", "font_size": 11},
            "export": {"format": "docx", "pdf_password": "", "compress_images": True, "template": None}
        }
        self.load()

    def load(self):
        if os.path.exists(SETTINGS_FILE):
            try:
                with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
                    d = json.load(f)
                self.data.update(d)
                exp = self.data.get('export', {})
                if 'format' not in exp:
                    exp['format'] = 'docx'
                if 'pdf_password' not in exp:
                    exp['pdf_password'] = ''
                if 'compress_images' not in exp:
                    exp['compress_images'] = True
                self.data['export'] = exp
                logger.log("Настройки загружены")
            except Exception as e:
                logger.log(f"Ошибка загрузки настроек: {e}")

    def save(self):
        try:
            with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            logger.log("Настройки сохранены")
        except Exception as e:
            logger.log(f"Ошибка сохранения настроек: {e}")

    def save_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            logger.log(f"Профиль сохранён: {filename}")
            return True
        except Exception as e:
            logger.log(f"Ошибка сохранения профиля: {e}")
            return False

    def load_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'r', encoding='utf-8') as f:
                d = json.load(f)
            self.data.update(d)
            logger.log(f"Профиль загружен: {filename}")
            return True
        except Exception as e:
            logger.log(f"Ошибка загрузки профиля: {e}")
            return False

settings = Settings()

class Placeholders:
    def __init__(self):
        self.placeholders = {}
        self.load()

    def load(self):
        if os.path.isfile(PLACEHOLDERS_FILE):
            try:
                with open(PLACEHOLDERS_FILE, 'r', encoding='utf-8') as f:
                    self.placeholders = json.load(f)
                logger.log("Плейсхолдеры загружены")
            except Exception as e:
                logger.log(f"Ошибка загрузки плейсхолдеров: {e}")

    def save(self):
        try:
            with open(PLACEHOLDERS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.placeholders, f, ensure_ascii=False, indent=4)
            logger.log("Плейсхолдеры сохранены")
        except Exception as e:
            logger.log(f"Ошибка сохранения плейсхолдеров: {e}")

placeholders = Placeholders()

def extract_article_full(obj):
    if not isinstance(obj, str) or not obj.strip():
        return ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        return obj[idx + 4:].strip()
    words = obj.strip().split()
    if len(words) <= 2:
        return obj.strip()
    return " ".join(words[-3:]).strip()

def extract_product_type(obj):
    if not isinstance(obj, str) or not obj.strip():
        return ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        part = obj[:idx].strip()
        if part:
            return part
        return "Неизвестно"
    words = obj.strip().split()
    if len(words) <= 3:
        return " ".join(words)
    return " ".join(words[:3])

def extract_param(prop):
    if not isinstance(prop, str):
        return ""
    return prop.split("_")[-1].strip()

def load_and_transform_excel_grouped(path):
    logger.log(f"Загрузка файла: {path}")
    df = pd.read_excel(path, engine="openpyxl")
    missing = [c for c in MANDATORY_COLS if c not in df.columns]
    if missing:
        raise ValueError(f"Отсутствуют колонки: {missing}")

    df["Тип товара"] = df["Объект"].apply(extract_product_type)
    df["Артикул"] = df["Объект"].apply(extract_article_full)
    df["Параметр"] = df["Свойство"].apply(extract_param)
    df["Значение"] = df["Значение"].astype(str)
    df = df[(df["Артикул"] != "") & (df["Параметр"] != "")].reset_index(drop=True)

    grouped = {}
    for art, gdf in df.groupby("Артикул"):
        obj_full = gdf["Объект"].iloc[0]
        prod_type = gdf["Тип товара"].iloc[0]

        sections = []
        current_header = None
        current_params = []

        for _, row in gdf.iterrows():
            if row["Значение"].strip().lower() == "<раздел>":
                if current_header is not None or current_params:
                    sections.append({"header": current_header, "params": current_params})
                current_header = row["Параметр"]
                current_params = []
            else:
                current_params.append({"parameter": row["Параметр"], "value": row["Значение"]})

        if current_header is not None or current_params:
            sections.append({"header": current_header, "params": current_params})

        if not sections:
            sections = [
                {
                    "header": None,
                    "params": [{"parameter": r["Параметр"], "value": r["Значение"]} for _, r in gdf.iterrows()],
                }
            ]

        grouped[art] = {"object_full": obj_full, "product_type": prod_type, "sections": sections}
    logger.log(f"Данные сгруппированы по артикулам: {len(grouped)}")
    return grouped

def inline_replace_in_paragraph(paragraph, placeholder, replacement):
    runs = paragraph.runs
    full_text = "".join(run.text for run in runs)
    if placeholder not in full_text:
        return
    new_text = full_text.replace(placeholder, replacement)
    for run in runs:
        run.text = ""
    if runs:
        runs[0].text = new_text

def docx_replace_placeholder(doc, key, value):
    ph = "{{" + key + "}}"
    for p in doc.paragraphs:
        if ph in p.text:
            inline_replace_in_paragraph(p, ph, str(value))
    for tbl in doc.tables:
        for row in tbl.rows:
            for cell in row.cells:
                for p in cell.paragraphs:
                    if ph in p.text:
                        inline_replace_in_paragraph(p, ph, str(value))

def add_section_table(doc, section, style):
    if section["header"]:
        p = doc.add_paragraph()
        run = p.add_run(section["header"])
        run.bold = True
        run.font.size = Pt(style.get("font_size", 12))
        p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    tbl = doc.add_table(rows=1, cols=2)

    hdr_cells = tbl.rows[0].cells
    hdr_cells[0].text = "Параметр"
    hdr_cells[1].text = "Значение"
    for c in hdr_cells:
        p_cell = c.paragraphs[0]
        for r in p_cell.runs:
            r.bold = style.get("header_bold", True)
            r.font.name = style.get("font_name", "Calibri")
            r.font.size = Pt(style.get("font_size", 11))
        alignment = style.get("alignment", "center")
        p_cell.alignment = {
            "left": WD_ALIGN_PARAGRAPH.LEFT,
            "right": WD_ALIGN_PARAGRAPH.RIGHT,
            "center": WD_ALIGN_PARAGRAPH.CENTER,
        }[alignment]
    for param in section["params"]:
        row_cells = tbl.add_row().cells
        row_cells[0].text = str(param["parameter"])
        row_cells[1].text = str(param["value"])
        for i in (0, 1):
            p_cell = row_cells[i].paragraphs[0]
            for r in p_cell.runs:
                r.font.name = style.get("font_name", "Calibri")
                r.font.size = Pt(style.get("font_size", 11))
            p_cell.alignment = {
                "left": WD_ALIGN_PARAGRAPH.LEFT,
                "right": WD_ALIGN_PARAGRAPH.RIGHT,
                "center": WD_ALIGN_PARAGRAPH.CENTER,
            }[style.get("alignment", "center")]

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        global app_ref
        app_ref = self

        self.title("Автоматизация техдокументации")
        self.geometry("1350x820")

        self.grouped_data = {}
        self.filtered_df = pd.DataFrame()
        self.filtered_df_backup = pd.DataFrame()
        self.basket_df = pd.DataFrame(
            columns=["Объект", "Тип товара", "Артикул", "Параметр", "Значение"]
        )

        self.user_placeholders = placeholders.placeholders.copy()
        self.templates = []
        self.current_template = None

        self.selected_profile = None

        self.create_widgets()
        self.create_menu()
        self.bind_shortcuts()
        self.load_templates()
        self.load_profiles()
        self.load_placeholders_to_tree()
        self.load_settings_to_gui()

        logger.log("Приложение запущено")

    def create_widgets(self):
        self.tabs = ttk.Notebook(self)
        self.tabs.pack(fill="both", expand=True)

        # Вкладка "Данные"
        tab_data = ttk.Frame(self.tabs)
        self.tabs.add(tab_data, text="Данные")

        # Левая часть: список товаров
        left_frame = ttk.Frame(tab_data)
        left_frame.pack(side="left", fill="both", expand=True, padx=5, pady=5)

        fsearch = ttk.Frame(left_frame)
        fsearch.pack(fill="x", padx=5, pady=3)
        ttk.Label(fsearch, text="Поиск по артикулу:").pack(side="left")
        self.var_quick_search = tk.StringVar()
        ent_qs = ttk.Entry(fsearch, textvariable=self.var_quick_search)
        ent_qs.pack(side="left", fill="x", expand=True, padx=5)
        ent_qs.bind("<KeyRelease>", self.quick_search)

        ffilter = ttk.Frame(left_frame)
        ffilter.pack(fill="x", padx=5, pady=3)
        ttk.Label(ffilter, text="Фильтр по колонке:").pack(side="left")
        self.combo_filter_col = ttk.Combobox(ffilter, state="readonly", width=20)
        self.combo_filter_col.pack(side="left", padx=5)
        ttk.Label(ffilter, text="Оператор:").pack(side="left")
        self.combo_filter_op = ttk.Combobox(
            ffilter,
            state="readonly",
            values=["содержит", "равно", "начинается с", "оканчивается на"],
            width=15,
        )
        self.combo_filter_op.set("содержит")
        self.combo_filter_op.pack(side="left", padx=5)
        self.var_filter_val = tk.StringVar()
        ent_fv = ttk.Entry(ffilter, textvariable=self.var_filter_val)
        ent_fv.pack(side="left", fill="x", expand=True, padx=5)
        ent_fv.bind("<KeyRelease>", self.advanced_filter)
        self.combo_filter_op.bind("<<ComboboxSelected>>", lambda e: self.advanced_filter())
        self.combo_filter_col.bind("<<ComboboxSelected>>", lambda e: self.advanced_filter())
        ttk.Button(ffilter, text="Очистить", command=self.clear_filter).pack(side="left", padx=5)

        tbl_frame = ttk.Frame(left_frame)
        tbl_frame.pack(fill="both", expand=True, padx=5, pady=5)
        self.tree_data = ttk.Treeview(tbl_frame, show="headings", selectmode="extended")
        self.tree_data.pack(side="left", fill="both", expand=True)
        self.tree_data.bind("<Double-1>", self.add_selected_to_basket)
        self.tree_data.bind("<Button-1>", self.show_item_params)
        self.tree_data.bind("<Button-3>", self.context_menu_data)

        scr_y = ttk.Scrollbar(tbl_frame, orient="vertical", command=self.tree_data.yview)
        scr_y.pack(side="left", fill="y")
        scr_x = ttk.Scrollbar(tbl_frame, orient="horizontal", command=self.tree_data.xview)
        scr_x.pack(side="bottom", fill="x")
        self.tree_data.configure(yscrollcommand=scr_y.set, xscrollcommand=scr_x.set)

        btn_frame = ttk.Frame(left_frame)
        btn_frame.pack(fill="x", padx=5, pady=5)
        ttk.Button(btn_frame, text="Загрузить Excel", command=self.load_excel_dialog).pack(side="left")
        ttk.Button(btn_frame, text="Добавить в корзину", command=self.add_selected_to_basket).pack(side="left", padx=5)

        # Правая часть: параметры выбранного товара
        right_frame = ttk.Frame(tab_data)
        right_frame.pack(side="right", fill="both", expand=True, padx=5, pady=5)

        self.tree_params = ttk.Treeview(right_frame, show="headings", columns=["Параметр", "Значение"])
        self.tree_params.pack(fill="both", expand=True)
        self.tree_params.heading("Параметр", text="Параметр")
        self.tree_params.heading("Значение", text="Значение")
        self.tree_params.column("Параметр", width=150, anchor="w")
        self.tree_params.column("Значение", width=150, anchor="w")

        # Корзина внизу окна
        basket_frame = ttk.Frame(self)
        basket_frame.pack(side="bottom", fill="x", padx=5, pady=5)

        self.tree_basket = ttk.Treeview(basket_frame, show="headings", selectmode="extended")
        self.tree_basket.pack(side="left", fill="both", expand=True)
        self.tree_basket.bind("<Delete>", lambda e: self.remove_selected_from_basket())
        self.tree_basket.bind("<Button-3>", self.context_menu_basket)

        sb_basket_y = ttk.Scrollbar(basket_frame, orient="vertical", command=self.tree_basket.yview)
        sb_basket_y.pack(side="left", fill="y")
        sb_basket_x = ttk.Scrollbar(basket_frame, orient="horizontal", command=self.tree_basket.xview)
        sb_basket_x.pack(side="bottom", fill="x")
        self.tree_basket.configure(yscrollcommand=sb_basket_y.set, xscrollcommand=sb_basket_x.set)

        ctrl_basket = ttk.Frame(basket_frame)
        ctrl_basket.pack(fill="x", padx=5, pady=5)
        ttk.Button(ctrl_basket, text="Удалить выделенные", command=self.remove_selected_from_basket).pack(side="left", padx=5)
        ttk.Button(ctrl_basket, text="Очистить корзину", command=self.clear_basket).pack(side="left", padx=5)
        ttk.Button(ctrl_basket, text="Показать текст корзины", command=self.show_basket_text).pack(side="left", padx=5)

        self.txt_basket_text = tk.Text(basket_frame, height=10)
        self.txt_basket_text.pack(fill="both", padx=5, pady=5)

        # Вкладка "Настройки и Генерация"
        tab_set_gen = ttk.Frame(self.tabs)
        self.tabs.add(tab_set_gen, text="Настройки и Генерация")

        prof_frame = ttk.LabelFrame(tab_set_gen, text="Профили настроек")
        prof_frame.pack(fill='x', padx=10, pady=5)
        self.combo_profiles = ttk.Combobox(prof_frame, state='readonly')
        self.combo_profiles.pack(side='left', fill='x', expand=True, padx=5, pady=5)
        self.combo_profiles.bind("<<ComboboxSelected>>", self.on_profile_change)
        ttk.Button(prof_frame, text="Загрузить профиль", command=self.load_profile).pack(side='left', padx=5)
        ttk.Button(prof_frame, text="Сохранить профиль", command=self.save_profile).pack(side='left', padx=5)
        ttk.Button(prof_frame, text="Обновить список", command=self.load_profiles).pack(side='left', padx=5)

        ttk.Label(tab_set_gen, text="Выбор шаблона:").pack(anchor='w', padx=10)
        self.combo_templates = ttk.Combobox(tab_set_gen, state='readonly', width=70)
        self.combo_templates.pack(fill='x', padx=10, pady=5)
        self.combo_templates.bind("<<ComboboxSelected>>", self.on_template_change)
        ttk.Button(tab_set_gen, text="Предпросмотр шаблона", command=self.preview_template).pack(anchor='w', padx=10)

        ttk.Label(tab_set_gen, text="Плейсхолдеры:").pack(anchor='w', padx=10, pady=3)
        self.tree_placeholders = ttk.Treeview(tab_set_gen, columns=("name", "value"), show='headings', height=10)
        self.tree_placeholders.pack(fill='both', padx=10, pady=3, expand=True)
        self.tree_placeholders.heading("name", text="Имя")
        self.tree_placeholders.heading("value", text="Значение")
        self.tree_placeholders.column("name", width=200)
        self.tree_placeholders.column("value", width=400)

        ph_btn_frame = ttk.Frame(tab_set_gen)
        ph_btn_frame.pack(fill='x', padx=10, pady=1)
        ttk.Button(ph_btn_frame, text="Добавить", command=self.add_placeholder).pack(side='left', padx=5)
        ttk.Button(ph_btn_frame, text="Редактировать", command=self.edit_placeholder).pack(side='left', padx=5)
        ttk.Button(ph_btn_frame, text="Удалить", command=self.delete_placeholder).pack(side='left', padx=5)
        ttk.Button(ph_btn_frame, text="Импорт из JSON", command=self.import_placeholders).pack(side='left', padx=5)
        ttk.Button(ph_btn_frame, text="Экспорт в JSON", command=self.export_placeholders).pack(side='left', padx=5)

        style_label = ttk.Label(tab_set_gen, text="Параметры таблицы:")
        style_label.pack(anchor='w', padx=10, pady=(10, 3))
        style_frm = ttk.Frame(tab_set_gen)
        style_frm.pack(fill='x', padx=10, pady=5)
        self.var_header_bold = tk.BooleanVar(value=settings.data['table_style']['header_bold'])
        ttk.Checkbutton(style_frm, text="Жирный заголовок", variable=self.var_header_bold).grid(row=0, column=0, padx=5, pady=2)
        ttk.Label(style_frm, text="Выравнивание:").grid(row=0, column=1, padx=5, pady=2, sticky='w')
        self.combo_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_align.set(settings.data['table_style']['alignment'])
        self.combo_align.grid(row=0, column=2, padx=5, pady=2)
        ttk.Label(style_frm, text="Шрифт:").grid(row=0, column=3, padx=5, pady=2, sticky='w')
        self.entry_font_name = ttk.Entry(style_frm, width=15)
        self.entry_font_name.insert(0, settings.data['table_style']['font_name'])
        self.entry_font_name.grid(row=0, column=4, padx=5, pady=2)
        ttk.Label(style_frm, text="Размер:").grid(row=0, column=5, padx=5, pady=2, sticky='w')
        self.spin_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_font_size.delete(0, 'end')
        self.spin_font_size.insert(0, settings.data['table_style']['font_size'])
        self.spin_font_size.grid(row=0, column=6, padx=5, pady=2)

        export_label = ttk.Label(tab_set_gen, text="Настройки экспорта:")
        export_label.pack(anchor='w', padx=10, pady=(10, 3))
        export_frm = ttk.Frame(tab_set_gen)
        export_frm.pack(fill='x', padx=10, pady=5)
        ttk.Label(export_frm, text="Формат файла:").pack(side='left', padx=5)
        self.combo_export_format = ttk.Combobox(export_frm, values=['docx', 'pdf'], state='readonly', width=10)
        self.combo_export_format.set(settings.data['export']['format'])
        self.combo_export_format.pack(side='left', padx=5)
        self.var_compress_images = tk.BooleanVar(value=settings.data['export']['compress_images'])
        ttk.Checkbutton(export_frm, text="Сжимать изображения", variable=self.var_compress_images).pack(side='left', padx=10)
        ttk.Label(export_frm, text="Пароль для PDF:").pack(side='left', padx=5)
        self.entry_pdf_password = ttk.Entry(export_frm, width=20, show='*')
        self.entry_pdf_password.insert(0, settings.data['export']['pdf_password'])
        self.entry_pdf_password.pack(side='left', padx=5)

        ttk.Button(tab_set_gen, text="Сохранить настройки в профиль", command=self.save_profile).pack(padx=10, pady=10, anchor='e')
        ttk.Button(tab_set_gen, text="Предпросмотр итогового документа", command=self.preview_generated_document).pack(padx=10, pady=5, anchor='e')
        ttk.Button(tab_set_gen, text="Генерировать документ", command=self.generate_document).pack(padx=10, pady=5, anchor='e')

        # Вкладка "Лог"
        tab_log = ttk.Frame(self.tabs)
        self.tabs.add(tab_log, text="Лог")
        self.txt_log = tk.Text(tab_log, state='disabled', wrap='word')
        self.txt_log.pack(fill='both', expand=True, padx=5, pady=5)
        ttk.Button(tab_log, text="Экспорт лога", command=self.export_log).pack(pady=5)

    def create_menu(self):
        menubar = tk.Menu(self)
        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="Открыть Excel (Ctrl+O)", command=self.load_excel_dialog)
        file_menu.add_command(label="Генерировать документ (Ctrl+S)", command=self.generate_document)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.quit)
        menubar.add_cascade(label="Файл", menu=file_menu)
        self.config(menu=menubar)

    def bind_shortcuts(self):
        self.bind("<Control-o>", lambda e: self.load_excel_dialog())
        self.bind("<Control-s>", lambda e: self.generate_document())

    def load_excel_dialog(self):
        path = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel Files", "*.xlsx *.xls")])
        if not path:
            return
        try:
            self.grouped_data = load_and_transform_excel_grouped(path)
            rows = []
            for art, info in self.grouped_data.items():
                obj = info["object_full"]
                ptype = info["product_type"]
                rows.append({"Объект": obj, "Тип товара": ptype, "Артикул": art})
            self.filtered_df = pd.DataFrame(rows)
            self.filtered_df_backup = self.filtered_df.copy()
            self.update_data_table()
            self.combo_filter_col["values"] = list(self.filtered_df.columns)
            if not self.combo_filter_col.get():
                self.combo_filter_col.set(self.filtered_df.columns[0])
            self.basket_df = pd.DataFrame(columns=self.filtered_df.columns)
            self.update_basket_table()
            messagebox.showinfo("Успех", f"Данные загружены: {len(self.filtered_df)} записей")
            logger.log(f"Файл загружен: {path}, записей: {len(self.filtered_df)}")
        except Exception as e:
            traceback.print_exc()
            messagebox.showerror("Ошибка загрузки", str(e))
            logger.log(f"Ошибка загрузки: {e}")

    def update_data_table(self):
        self.tree_data.delete(*self.tree_data.get_children())
        if self.filtered_df.empty:
            self.tree_data["columns"] = []
            return
        self.tree_data["columns"] = list(self.filtered_df.columns)
        for col in self.filtered_df.columns:
            self.tree_data.heading(col, text=col)
            self.tree_data.column(col, width=160, anchor="w")
        for _, row in self.filtered_df.iterrows():
            self.tree_data.insert("", "end", values=list(row))

    def quick_search(self, event=None):
        if self.filtered_df_backup.empty:
            return
        query = self.var_quick_search.get().lower().strip()
        if not query:
            self.filtered_df = self.filtered_df_backup.copy()
        else:
            self.filtered_df = self.filtered_df_backup[
                self.filtered_df_backup['Артикул'].str.lower().str.contains(query, na=False)
            ]
        self.update_data_table()

    def advanced_filter(self, event=None):
        if self.filtered_df_backup.empty:
            return
        col = self.combo_filter_col.get()
        op = self.combo_filter_op.get()
        val = self.var_filter_val.get().lower().strip()
        if not col or not val:
            self.filtered_df = self.filtered_df_backup.copy()
            self.update_data_table()
            return
        series = self.filtered_df_backup[col].astype(str).str.lower()
        mask = None
        if op == "содержит":
            mask = series.str.contains(val, na=False)
        elif op == "равно":
            mask = series == val
        elif op == "начинается с":
            mask = series.str.startswith(val, na=False)
        elif op == "оканчивается на":
            mask = series.str.endswith(val, na=False)
        if mask is not None:
            self.filtered_df = self.filtered_df_backup[mask]
            self.update_data_table()

    def clear_filter(self):
        self.var_quick_search.set("")
        self.var_filter_val.set("")
        if hasattr(self, 'filtered_df_backup'):
            self.filtered_df = self.filtered_df_backup.copy()
            self.update_data_table()

    def show_item_params(self, event):
        sel = self.tree_data.selection()
        if not sel:
            return
        iid = sel[0]
        item = self.tree_data.item(iid)
        art = item["values"][2]
        if art in self.grouped_data:
            params = self.grouped_data[art]["sections"][0]["params"]
            self.tree_params.delete(*self.tree_params.get_children())
            for param in params:
                self.tree_params.insert("", "end", values=(param["parameter"], param["value"]))

    def add_selected_to_basket(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            messagebox.showinfo("Добавление", "Выделите записи для добавления в корзину")
            return
        rows = [self.tree_data.item(iid)["values"] for iid in sel]
        df_add = pd.DataFrame(rows, columns=self.filtered_df.columns)
        self.basket_df = pd.concat([self.basket_df, df_add], ignore_index=True)
        self.update_basket_table()
        logger.log(f"В корзину добавлено: {len(rows)} записей")

    def update_basket_table(self):
        self.tree_basket.delete(*self.tree_basket.get_children())
        if self.basket_df.empty:
            self.tree_basket["columns"] = []
            return
        self.tree_basket["columns"] = list(self.basket_df.columns)
        for col in self.basket_df.columns:
            self.tree_basket.heading(col, text=col)
            self.tree_basket.column(col, width=160, anchor="w")
        for _, row in self.basket_df.iterrows():
            self.tree_basket.insert("", "end", values=list(row))

    def remove_selected_from_basket(self):
        sel = self.tree_basket.selection()
        if not sel:
            messagebox.showinfo("Удаление", "Выделите записи для удаления из корзины")
            return
        idxs = [self.tree_basket.index(iid) for iid in sel]
        self.basket_df.drop(self.basket_df.index[idxs], inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        logger.log(f"Удалено из корзины: {len(idxs)} записей")

    def clear_basket(self):
        if messagebox.askyesno("Очистка корзины", "Очистить корзину?"):
            self.basket_df = pd.DataFrame(columns=self.filtered_df.columns)
            self.update_basket_table()
            logger.log("Корзина очищена")

    def show_basket_text(self):
        self.txt_basket_text.delete("1.0", "end")
        if self.basket_df.empty:
            self.txt_basket_text.insert("end", "(Корзина пуста)")
        else:
            self.txt_basket_text.insert("end", self.basket_df.to_string(index=False))

    def context_menu_data(self, event):
        iid = self.tree_data.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Добавить в корзину", command=self.add_selected_to_basket)
        menu.post(event.x_root, event.y_root)

    def context_menu_basket(self, event):
        iid = self.tree_basket.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Удалить из корзины", command=self.remove_selected_from_basket)
        menu.post(event.x_root, event.y_root)

    def preview_template(self):
        if not self.current_template:
            messagebox.showwarning("Предпросмотр", "Шаблон не выбран")
            return
        path = os.path.join(TEMPLATES_DIR, self.current_template)
        try:
            if os.name == 'nt':
                os.startfile(path)
            else:
                try:
                    subprocess.Popen(["open", path])
                except:
                    subprocess.Popen(["xdg-open", path])
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка предпросмотра шаблона:\n{e}")

    def preview_generated_document(self):
        if self.basket_df.empty:
            messagebox.showwarning("Предпросмотр", "Корзина пуста")
            return
        if not self.current_template:
            messagebox.showwarning("Предпросмотр", "Шаблон не выбран")
            return
        try:
            with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as tmpf:
                tmpf.close()
                self.generate_document(path=tmpf.name, show_messages=False)
                if os.name == 'nt':
                    os.startfile(tmpf.name)
                else:
                    try:
                        subprocess.Popen(["open", tmpf.name])
                    except:
                        subprocess.Popen(["xdg-open", tmpf.name])
        except Exception as e:
            messagebox.showerror("Ошибка предпросмотра", str(e))

    def save_profile(self):
        try:
            self.save_gui_to_settings()
            if not self.selected_profile:
                prof_name = simpledialog.askstring("Сохранить профиль", "Имя профиля (без расширения):", parent=self)
                if not prof_name:
                    messagebox.showinfo("Отмена", "Сохранение отменено")
                    return
                fname = prof_name.strip() + ".json"
            else:
                fname = self.selected_profile
            if settings.save_profile(fname):
                self.selected_profile = fname
                self.load_profiles()
                self.combo_profiles.set(fname)
                messagebox.showinfo("Успех", f"Профиль {fname} сохранён")
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка сохранения профиля:\n{e}")

    def load_profile(self):
        prof = self.combo_profiles.get()
        if not prof:
            return
        if settings.load_profile(prof):
            self.selected_profile = prof
            self.load_settings_to_gui()
            messagebox.showinfo("Успех", f"Профиль {prof} загружен")

    def load_profiles(self):
        if not os.path.exists(PROFILES_DIR):
            os.makedirs(PROFILES_DIR)
        files = sorted([f for f in os.listdir(PROFILES_DIR) if f.endswith('.json')])
        self.combo_profiles['values'] = files
        if files and not self.selected_profile:
            self.combo_profiles.set(files[0])
            self.selected_profile = files[0]

    def on_profile_change(self, event=None):
        selected = self.combo_profiles.get()
        if selected:
            self.selected_profile = selected
            if settings.load_profile(selected):
                self.load_settings_to_gui()
                messagebox.showinfo("Профиль", f"Профиль '{selected}' загружен")
            else:
                messagebox.showerror("Ошибка", f"Ошибка загрузки профиля: {selected}")

    def on_template_change(self, event=None):
        selected = self.combo_templates.get()
        if selected:
            self.current_template = selected
            logger.log(f"Выбран шаблон: {selected}")

    def load_placeholders_to_tree(self):
        self.user_placeholders = placeholders.placeholders.copy()
        self.refresh_placeholders_view()

    def refresh_placeholders_view(self):
        self.tree_placeholders.delete(*self.tree_placeholders.get_children())
        for k, v in self.user_placeholders.items():
            self.tree_placeholders.insert("", "end", values=(k, v))

    def add_placeholder(self):
        name = simpledialog.askstring("Добавить плейсхолдер", "Имя плейсхолдера:", parent=self)
        if not name:
            return
        if name in self.user_placeholders:
            messagebox.showerror("Ошибка", "Такой плейсхолдер уже существует")
            return
        value = simpledialog.askstring("Добавить плейсхолдер", "Значение плейсхолдера:", parent=self)
        if value is None:
            return
        self.user_placeholders[name] = value
        placeholders.placeholders = self.user_placeholders.copy()
        placeholders.save()
        self.refresh_placeholders_view()
        logger.log(f"Добавлен плейсхолдер: {name} = {value}")

    def edit_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Редактировать", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name, old_value = self.tree_placeholders.item(iid, "values")
        new_value = simpledialog.askstring("Редактировать плейсхолдер", f"Значение плейсхолдера '{name}':", initialvalue=old_value, parent=self)
        if new_value is None:
            return
        self.user_placeholders[name] = new_value
        placeholders.placeholders = self.user_placeholders.copy()
        placeholders.save()
        self.refresh_placeholders_view()
        logger.log(f"Изменён плейсхолдер: {name} = {new_value}")

    def delete_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Удалить", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name = self.tree_placeholders.item(iid, "values")[0]
        if messagebox.askyesno("Удалить плейсхолдер", f"Удалить плейсхолдер '{name}'?"):
            self.user_placeholders.pop(name, None)
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            logger.log(f"Удалён плейсхолдер: {name}")

    def import_placeholders(self):
        path = filedialog.askopenfilename(title="Импорт плейсхолдеров из JSON", filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'r', encoding='utf-8') as f:
                d = json.load(f)
            if not isinstance(d, dict):
                raise ValueError("Файл должен содержать словарь плейсхолдеров")
            self.user_placeholders.update(d)
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            messagebox.showinfo("Импорт", f"Импортировано {len(d)} плейсхолдеров")
            logger.log(f"Импортировано плейсхолдеров: {len(d)}")
        except Exception as e:
            messagebox.showerror("Ошибка импорта", str(e))
            logger.log(f"Ошибка импорта плейсхолдеров: {e}")

    def export_placeholders(self):
        path = filedialog.asksaveasfilename(title="Экспорт плейсхолдеров в JSON", defaultextension=".json",
                                            filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'w', encoding='utf-8') as f:
                json.dump(self.user_placeholders, f, ensure_ascii=False, indent=4)
            messagebox.showinfo("Экспорт", f"Плейсхолдеры экспортированы: {path}")
            logger.log(f"Плейсхолдеры экспортированы: {path}")
        except Exception as e:
            messagebox.showerror("Ошибка экспорта", str(e))
            logger.log(f"Ошибка экспорта плейсхолдеров: {e}")

    def export_log(self):
        path = filedialog.asksaveasfilename(title="Сохранить лог", defaultextension=".txt",
                                            filetypes=[("Text files", "*.txt")])
        if not path:
            return
        logger.save_to_file(path)
        messagebox.showinfo("Лог сохранён", f"Лог сохранён в файл:\n{path}")

    def save_gui_to_settings(self):
        d = settings.data
        exp = d.setdefault("export", {})
        exp["format"] = self.combo_export_format.get() if hasattr(self, "combo_export_format") else "docx"
        exp["compress_images"] = self.var_compress_images.get() if hasattr(self, "var_compress_images") else True
        exp["pdf_password"] = self.entry_pdf_password.get() if hasattr(self, "entry_pdf_password") else ""
        exp["template"] = self.combo_templates.get() if hasattr(self, "combo_templates") else None

        ts = d.setdefault("table_style", {})
        ts["header_bold"] = self.var_header_bold.get() if hasattr(self, "var_header_bold") else True
        ts["alignment"] = self.combo_align.get() if hasattr(self, "combo_align") else "center"
        ts["font_name"] = self.entry_font_name.get() if hasattr(self, "entry_font_name") else "Calibri"
        try:
            ts["font_size"] = int(self.spin_font_size.get()) if hasattr(self, "spin_font_size") else 11
        except:
            ts["font_size"] = 11

        settings.save()

    def load_settings_to_gui(self):
        d = settings.data
        ex = d.get("export", {})
        ts = d.get("table_style", {})

        if hasattr(self, "combo_export_format"):
            self.combo_export_format.set(ex.get("format", "docx"))
        if hasattr(self, "var_compress_images"):
            self.var_compress_images.set(ex.get("compress_images", True))
        if hasattr(self, "entry_pdf_password"):
            self.entry_pdf_password.delete(0, "end")
            self.entry_pdf_password.insert(0, ex.get("pdf_password", ""))

        if hasattr(self, "var_header_bold"):
            self.var_header_bold.set(ts.get("header_bold", True))
        if hasattr(self, "combo_align"):
            self.combo_align.set(ts.get("alignment", "center"))
        if hasattr(self, "entry_font_name"):
            self.entry_font_name.delete(0, "end")
            self.entry_font_name.insert(0, ts.get("font_name", "Calibri"))
        if hasattr(self, "spin_font_size"):
            self.spin_font_size.delete(0, "end")
            self.spin_font_size.insert(0, ts.get("font_size", 11))

        if hasattr(self, "combo_templates"):
            self.load_templates()
            tmpl = ex.get("template")
            if self.templates:
                if tmpl in self.templates:
                    self.combo_templates.set(tmpl)
                    self.current_template = tmpl
                else:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def load_templates(self):
        self.templates = sorted([f for f in os.listdir(TEMPLATES_DIR) if f.lower().endswith(".docx")])
        if hasattr(self, "combo_templates"):
            self.combo_templates["values"] = self.templates
            if self.templates:
                if self.current_template not in self.templates:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def generate_document(self, path=None, show_messages=True):
        if self.basket_df.empty:
            if show_messages:
                messagebox.showwarning("Генерация", "Корзина пуста")
            return
        if not self.current_template:
            if show_messages:
                messagebox.showwarning("Генерация", "Шаблон не выбран")
            return

        self.save_gui_to_settings()

        if not path:
            path = filedialog.asksaveasfilename(title="Сохранить документ",
                                                defaultextension=".docx",
                                                filetypes=[("Word Document", "*.docx")])
            if not path:
                return

        try:
            template_path = os.path.join(TEMPLATES_DIR, self.current_template)
            doc = Document(template_path)

            # Собираем плейсхолдеры из пользовательских
            phs = self.user_placeholders.copy()

            # Уникальные артикула в корзине
            unique_arts = list(dict.fromkeys(self.basket_df["Артикул"]))

            art_params = {}
            art_info = {}

            # Формируем словари данных для каждого артикула
            for art in unique_arts:
                sub_df = self.basket_df[self.basket_df["Артикул"] == art]
                if not sub_df.empty:
                    art_info[art] = {
                        "Объект": sub_df.iloc[0]["Объект"],
                        "Тип товара": sub_df.iloc[0]["Тип товара"],
                        "Артикул": art,
                    }
                else:
                    art_info[art] = {"Объект": "", "Тип товара": "", "Артикул": art}
                art_params[art] = []
                for _, r in sub_df.iterrows():
                    if str(r["Параметр"]).startswith("Раздел:"):
                        continue
                    art_params[art].append({"parameter": r["Параметр"], "value": r["Значение"]})

            # Замена основных базовых плейсхолдеров из первого артикула
            if unique_arts:
                first_art = unique_arts[0]
                for key in ["Тип товара", "Артикул", "Объект"]:
                    val = art_info[first_art].get(key, "")
                    phs[key] = val

            # Основная замена пользовательских плейсхолдеров
            for k, v in phs.items():
                docx_replace_placeholder(doc, k, v)

            # Ищем параграфы с {{Таблица}} и заменяем их на таблицы параметров по каждому артикулу
            replaced_table_placeholders = 0
            paras = list(doc.paragraphs)
            for i, p in enumerate(paras):
                if "{{Таблица}}" in p.text:
                    replaced_table_placeholders += 1
                    p._element.getparent().remove(p._element)
                    for art in unique_arts:
                        p1 = doc.add_paragraph()
                        run = p1.add_run(f"Артикул: {art_info[art].get('Артикул', '')}")
                        run.bold = True
                        run.font.size = Pt(14)
                        p1.alignment = WD_ALIGN_PARAGRAPH.LEFT

                        p2 = doc.add_paragraph()
                        run2 = p2.add_run(f"Объект: {art_info[art].get('Объект', '')}")
                        run2.font.size = Pt(12)
                        p2.alignment = WD_ALIGN_PARAGRAPH.LEFT

                        p3 = doc.add_paragraph()
                        run3 = p3.add_run(f"Тип товара: {art_info[art].get('Тип товара', '')}")
                        run3.font.size = Pt(12)
                        p3.alignment = WD_ALIGN_PARAGRAPH.LEFT

                        add_section_table(doc, {"header": None, "params": art_params[art]}, settings.data.get("table_style", {}))
                        doc.add_paragraph()

            if replaced_table_placeholders == 0:
                logger.log("Плейсхолдер {{Таблица}} не найден в шаблоне")

            doc.save(path)
            logger.log(f"Документ сгенерирован и сохранён: {path}")

            if settings.data.get("export", {}).get("format") == "pdf":
                pdf_path = os.path.splitext(path)[0] + ".pdf"
                if WIN32COM_AVAILABLE:
                    convert_docx_to_pdf(path, pdf_path)
                    if show_messages:
                        messagebox.showinfo("Успех", f"Документ сгенерирован и сохранён:\n{path}\nФормат PDF:\n{pdf_path}")
                else:
                    logger.log("Для создания PDF необходим MS Word и pywin32")
                    messagebox.showwarning("PDF", "Конвертация в PDF невозможна. Не найден пакет pywin32.")
            else:
                if show_messages:
                    messagebox.showinfo("Успех", f"Документ сгенерирован и сохранён:\n{path}")

        except Exception as e:
            traceback.print_exc()
            logger.log(f"Ошибка генерации документа: {e}")
            if show_messages:
                messagebox.showerror("Ошибка генерации", str(e))

def convert_docx_to_pdf(docx_path, pdf_path):
    word = win32com.client.Dispatch("Word.Application")
    doc = word.Documents.Open(docx_path)
    doc.SaveAs(pdf_path, FileFormat=17)  # 17 is the file format for PDF
    doc.Close()
    word.Quit()

def main():
    app = App()
    app.mainloop()

if __name__ == '__main__':
    main()

Сделай так, чтобы во втором скрипте тоже работала Генерация документа.