Respan Dataset Explorer

Select one behavior. Every returned turn has one binary label: Present or Absent. Source: final dense boolean release.

5,167,182physical rows
86shards
0.00%qualified row coverage
0.00%qualified cell coverage
Random row JSON API

turns-00079.parquet:36589

c46b44af71ee9f58a50835aa
turn 11/18gpt-4.1-mini-2025-04-14RussianRussia106 words
degenerate_repetitionAbsentFinal dense release
USER
Состав участников стадии подготовки дела к судебному разбирательству:
Вопрос 13Ответ

a.
суд и стороны;

b.
все возможные субъекты гражданского судопроизводства.

c.
только суд;

d.
суд, стороны, другие лица, участвующие в деле, и их представители;
ASSISTANT
Правильный ответ:

**d. суд, стороны, другие лица, участвующие в деле, и их представители**

Обоснование:  
Стадия подготовки дела к судебному разбирательству включает участие суда, сторон (истца и ответчика), а также других лиц, участвующих в деле (например, третьих лиц), и их представителей. Все они принимают участие в подготовке дела: представляют доказательства, заявляют ходатайства, участвуют в подготовительном заседании и т.д.

Вариант **a** (только суд и стороны) неполный, поскольку не учитывает других лиц, участвующих в деле.

turns-00079.parquet:36590

8f11db8b7f875f19c7c44c1e
turn 12/18gpt-4.1-mini-2025-04-14RussianRussia118 words
degenerate_repetitionAbsentFinal dense release
USER
ГПК РФ для принятия заявления и возбуждения гражданского судопроизводства по делу устанавливает для судьи следующий срок:
Вопрос 14Ответ

a.
в течение пяти дней с момента поступления искового заявления в суд;

b.
по истечении 10 дней с момента поступления искового заявления в суд;

c.
в течение трех дней с момента поступления искового заявления в суд.

d.
по истечении пяти дней с момента поступления искового заявления в суд;
ASSISTANT
Правильный ответ:

**a. в течение пяти дней с момента поступления искового заявления в суд**

Обоснование:  
Согласно статье 132 Гражданского процессуального кодекса РФ, судья обязан в течение пяти дней с момента поступления искового заявления в суд принять решение о его принятии или отказе в принятии, то есть о начале гражданского судопроизводства по делу.

turns-00079.parquet:36591

5a9a623c70cab2a87fa0caca
turn 13/18gpt-4.1-mini-2025-04-14RussianRussia131 words
degenerate_repetitionAbsentFinal dense release
USER
Определение о подготовке дела к судебному разбирательству должно быть вынесено:
Вопрос 15Ответ

a.
после возбуждения дела и принятия его к производству суда;

b.
в момент поступления заявления (искового заявления) в суд;

c.
после проведения судом собеседования со сторонами.

d.
выносить данное определение необязательно;
ASSISTANT
Правильный ответ:

**a. после возбуждения дела и принятия его к производству суда**

Обоснование:  
Определение о подготовке дела к судебному разбирательству выносится судом после того, как дело возбуждено и принято к производству. Это формальный этап, который обозначает начало подготовки дела к разбирательству в суде.

Вариант *b* неверен, так как подготовка к разбирательству начинается не сразу с момента поступления заявления, а после его принятия.  
Вариант *c* ошибочен, поскольку собеседование со сторонами не является обязательным этапом для вынесения такого определения.  
Вариант *d* тоже неверен — вынесение определения о подготовке дела обязательно.

turns-00079.parquet:36592

d4ab64311b44d45b0d9152f2
turn 14/18gpt-4.1-mini-2025-04-14RussianRussia139 words
degenerate_repetitionAbsentFinal dense release
USER
Судебное определение вступает в законную силу:
Вопрос 16Ответ

a.
по истечении срока на кассационное обжалование;

b.
после его реального исполнения;

c.
сразу после оглашения.

d.
по истечении срока на апелляционное обжалование;
ASSISTANT
Правильный ответ:

**a. по истечении срока на кассационное обжалование**

Обоснование:  
Согласно ГПК РФ, судебное определение вступает в законную силу после истечения сроков на обжалование, в том числе кассационного обжалования, если оно не было обжаловано ранее. При отсутствии обжалования решение (определение) приобретает юридическую силу именно по окончании этих сроков.

Вариант **c** — "сразу после оглашения" — верен для некоторых решений, но не для всех видов судебных определений, которые могут обжаловаться.

Вариант **d** — "по истечении срока на апелляционное обжалование" — менее точен, так как после апелляционной инстанции может следовать кассационная.

Вариант **b** — "после его реального исполнения" — не связано со вступлением в законную силу, а с исполнением решения.

turns-00079.parquet:36593

5e86284063f22a18e8da36d2
turn 15/18gpt-4.1-mini-2025-04-14RussianRussia98 words
degenerate_repetitionAbsentFinal dense release
USER
Судебное решение, которое не подлежит обязательному немедленному исполнению:
Вопрос 17Ответ

a.
выплате работнику заработной платы в течение трех месяцев;

b.
о взыскании алиментов;

c.
о восстановлении на работе.

d.
о расторжении брака;
ASSISTANT
Правильный ответ:

**d. о расторжении брака**

Обоснование:  
Решение о расторжении брака не подлежит обязательному немедленному исполнению в смысле принудительного исполнения, как, например, взыскание алиментов или восстановление на работе. Обычно этот процесс требует внесения записи в органах ЗАГС для официального прекращения брака.

В то время как решения о выплате заработной платы, взыскании алиментов и восстановлении на работе подлежат немедленному исполнению и могут быть принудительно исполнены судебными приставами.

turns-00079.parquet:36594

afd31ef28cc8daf729afa3d0
turn 16/18gpt-4.1-mini-2025-04-14RussianRussia113 words
degenerate_repetitionAbsentFinal dense release
USER
При подготовке дела к судебному разбирательству истец или его представитель:
Вопрос 18Ответ

a.
не обязаны передавать копии доказательств, обосновывающих фактические основания иска;

b.
обязаны передать ответчику копии доказательств, обосновывающих фактические основания иска;

c.
обязаны передать копии доказательств, обосновывающих фактические основания иска только в делах искового производства.

d.
обязаны передать копии доказательств, обосновывающих фактические основания иска только в случае, если ответчик заявит соответствующее ходатайство;
ASSISTANT
Правильный ответ:

**b. обязаны передать ответчику копии доказательств, обосновывающих фактические основания иска**

Обоснование:  
Согласно Гражданскому процессуальному кодексу РФ, истец обязан передать ответчику копии доказательств, на которые он ссылается при обосновании своих требований, чтобы ответчик мог подготовить защиту и дать свои пояснения. Это важный элемент состязательности и равенства сторон в суде.

turns-00079.parquet:36595

0603496069e0e1e0b226c25c
turn 17/18gpt-4.1-mini-2025-04-14RussianRussia80 words
degenerate_repetitionAbsentFinal dense release
USER
В необходимых случаях при рассмотрении и разрешении дела по существу эксперт дает заключение, которое для суда имеет:
Вопрос 19Ответ

a.
преимущество при оценке иных доказательств.

b.
рекомендательный характер;

c.
обязательный характер;

d.
вспомогательное значение;
ASSISTANT
Правильный ответ:

**d. вспомогательное значение**

Обоснование:  
Экспертное заключение является доказательством по делу и имеет вспомогательное значение для суда — оно помогает суду разобраться в специальных вопросах, но не обладает обязательной силой. Суд оценивает экспертное заключение вместе с другими доказательствами и самостоятельно решает, насколько этому заключению доверять.

turns-00079.parquet:36596

fc70f1ed8de0fcd8d9588d4c
turn 18/18gpt-4.1-mini-2025-04-14RussianRussia75 words
degenerate_repetitionAbsentFinal dense release
USER
Теория (концепция) иска отсутствует в науке ГПП:
Вопрос 20Ответ

a.
процессуальная;

b.
раздельная.

c.
материальная;

d.
субъективная;
ASSISTANT
Правильный ответ:

**c. материальная**

Обоснование:  
В науке гражданского процессуального права (ГПП) теория (концепция) иска обычно рассматривается с процессуальной, раздельной и субъективной точек зрения. Термин **"материальная теория иска"** чаще относится к материальному (гражданскому) праву, а не к процессуальному праву.

Следовательно, именно **материальная теория иска** отсутствует в науке гражданского процессуального права, поскольку относится к гражданскому праву, а не к процессуальному.

turns-00079.parquet:36597

b8ab314207fd2e19daef0b70
turn 1/1gpt-4.1-mini-2025-04-14EnglishRussia8215 words
degenerate_repetitionAbsentFinal dense release
USER
какой код лучше:
import os
import json
import traceback
from datetime import datetime
import subprocess

import pandas as pd
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.oxml.ns import qn, nsdecls
from docx.oxml import OxmlElement, parse_xml

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,
                "border_style": "тонкая",
                "section_header_font_name": "Calibri",
                "section_header_font_size": 12,
                "section_header_bold": True,
                "section_header_align": "center",
                "section_header_bg_color": "FFFF00"
            },
            "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

                ts = self.data.get('table_style', {})
                defaults = {
                    "border_style": "тонкая",
                    "section_header_font_name": "Calibri",
                    "section_header_font_size": 12,
                    "section_header_bold": True,
                    "section_header_align": "center",
                    "section_header_bg_color": "FFFF00"
                }
                for k, v in defaults.items():
                    if k not in ts:
                        ts[k] = v
                self.data['table_style'] = ts

                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)

            ts = self.data.get('table_style', {})
            defaults = {
                "border_style": "тонкая",
                "section_header_font_name": "Calibri",
                "section_header_font_size": 12,
                "section_header_bold": True,
                "section_header_align": "center",
                "section_header_bg_color": "FFFF00"
            }
            for k, v in defaults.items():
                if k not in ts:
                    ts[k] = v
            self.data['table_style'] = ts

            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))
    replace_placeholder_in_header_footer(doc, key, value)


def replace_placeholder_in_header_footer(doc, key, value):
    ph = "{{" + key + "}}"
    for section in doc.sections:
        header = section.header
        for p in header.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in header.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))
        footer = section.footer
        for p in footer.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in footer.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 set_table_border(table, border_style):
    if border_style == 'нет':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="nil"/>
            <w:left w:val="nil"/>
            <w:bottom w:val="nil"/>
            <w:right w:val="nil"/>
            <w:insideH w:val="nil"/>
            <w:insideV w:val="nil"/>
        </w:tblBorders>
        """ % nsdecls('w')
    elif border_style == 'тонкая':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')
    elif border_style == 'толстая':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="12" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')
    else:
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')

    tblPr = table._element.tblPr
    for child in tblPr.findall("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tblBorders"):
        tblPr.remove(child)
    borders = parse_xml(border_xml)
    tblPr.append(borders)


def set_cell_background(cell, hex_color):
    tc = cell._tc
    tcPr = tc.get_or_add_tcPr()
    shd = OxmlElement('w:shd')
    shd.set(qn('w:fill'), hex_color)
    tcPr.append(shd)


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

        self.title("Автоматизация техдокументации")
        self.geometry("1200x720")
        self.minsize(1000, 600)

        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)

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

        # Храним параметры и их возможные значения для выбора фильтров (множество значений для каждого параметра)
        self.all_params = []  # Список всех параметров
        self.param_values = {}  # { param_name: set(values) }

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

        self.filter_params_selected = {}  # {param_name: value} для нескольких параметров

        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()

        self.create_placeholders_from_loaded_products()

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

    def create_placeholders_from_loaded_products(self):
        if not self.grouped_data:
            logger.log("Данные товаров отсутствуют - автоматическое создание плейсхолдеров пропущено")
            return

        unique_params = set()
        for info in self.grouped_data.values():
            for section in info['sections']:
                for param in section['params']:
                    unique_params.add(param['parameter'])

        updated = False
        for param in unique_params:
            if param not in self.user_placeholders:
                self.user_placeholders[param] = "{{" + param + "}}"
                updated = True

        if updated:
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            logger.log(f"Автоматически создано плейсхолдеров из параметров: {len(unique_params)}")
        else:
            logger.log("Автоматическое создание плейсхолдеров: никаких новых плейсхолдеров не добавлено")

    def create_menu(self):
        menubar = tk.Menu(self)

        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="Загрузить Excel", command=self.load_excel_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="Сохранить лог...", command=self.export_log)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.quit)
        menubar.add_cascade(label="Файл", menu=file_menu)

        basket_menu = tk.Menu(menubar, tearoff=0)
        basket_menu.add_command(label="Удалить выделенные", command=self.remove_selected_from_basket)
        basket_menu.add_command(label="Очистить корзину", command=self.clear_basket)
        menubar.add_cascade(label="Корзина", menu=basket_menu)

        help_menu = tk.Menu(menubar, tearoff=0)
        help_menu.add_command(label="О программе", command=self.show_about)
        menubar.add_cascade(label="Помощь", menu=help_menu)

        self.config(menu=menubar)

    def bind_shortcuts(self):
        pass

    def show_about(self):
        messagebox.showinfo("О программе", "Автоматизация техдокументации\nВерсия 1.0")

    def create_widgets(self):
        self.nb = ttk.Notebook(self)
        self.nb.grid(row=0, column=0, sticky="nsew")

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

        tab_data.grid_rowconfigure(1, weight=1)
        tab_data.grid_columnconfigure(0, weight=1)
        tab_data.grid_columnconfigure(1, weight=0)

        filter_frame = ttk.Frame(tab_data)
        filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        filter_frame.grid_columnconfigure(7, weight=1)

        ttk.Label(filter_frame, text="Поиск по артикулу:").grid(row=0, column=0, sticky="w")
        self.var_quick_search = tk.StringVar()
        ent_quick_search = ttk.Entry(filter_frame, textvariable=self.var_quick_search)
        ent_quick_search.grid(row=0, column=1, sticky="ew", padx=3)
        filter_frame.grid_columnconfigure(1, weight=2)
        ent_quick_search.bind("<KeyRelease>", self.quick_search)

        ttk.Label(filter_frame, text="Фильтры параметров:").grid(row=0, column=2, sticky="w", padx=10)

        self.btn_add_param_filter = ttk.Button(filter_frame, text="+ Фильтр", command=self.add_param_filter_row)
        self.btn_add_param_filter.grid(row=0, column=7, sticky="e")

        self.param_filters_container = ttk.Frame(filter_frame)
        self.param_filters_container.grid(row=1, column=0, columnspan=8, sticky="ew", pady=5)
        self.param_filters_container.grid_columnconfigure(0, weight=1)

        self.param_filter_rows = []

        tbl_frame = ttk.Frame(tab_data)
        tbl_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        tbl_frame.grid_rowconfigure(0, weight=1)
        tbl_frame.grid_columnconfigure(0, weight=1)

        self.tree_data = ttk.Treeview(tbl_frame, show="headings", selectmode="extended")
        self.tree_data.grid(row=0, column=0, sticky="nsew")
        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.grid(row=0, column=1, sticky="ns")
        scr_x = ttk.Scrollbar(tbl_frame, orient="horizontal", command=self.tree_data.xview)
        scr_x.grid(row=1, column=0, sticky="ew")
        self.tree_data.configure(yscrollcommand=scr_y.set, xscrollcommand=scr_x.set)

        right_frame = ttk.Frame(tab_data, relief="groove", borderwidth=2)
        right_frame.grid(row=1, column=1, sticky="nsew", padx=5, pady=5)
        tab_data.grid_columnconfigure(1, weight=0)
        right_frame.grid_rowconfigure(1, weight=1)
        right_frame.grid_columnconfigure(0, weight=1)

        ttk.Label(right_frame, text="Параметры выбранного товара:").grid(row=0, column=0, sticky="w", padx=5, pady=(0, 5))
        self.tree_params = ttk.Treeview(right_frame, show="headings", columns=["Параметр", "Значение"], height=20)
        self.tree_params.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        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")

        sb_params_y = ttk.Scrollbar(right_frame, orient="vertical", command=self.tree_params.yview)
        sb_params_y.grid(row=1, column=1, sticky="ns")
        self.tree_params.configure(yscrollcommand=sb_params_y.set)

        basket_frame = ttk.LabelFrame(self, text="Корзина товаров")
        basket_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        self.grid_rowconfigure(1, weight=1)

        basket_frame.grid_rowconfigure(0, weight=1)
        basket_frame.grid_columnconfigure(0, weight=1)

        self.tree_basket = ttk.Treeview(basket_frame, show="headings", selectmode="extended", height=6)
        self.tree_basket.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
        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.grid(row=0, column=1, sticky="ns", pady=5)
        sb_basket_x = ttk.Scrollbar(basket_frame, orient="horizontal", command=self.tree_basket.xview)
        sb_basket_x.grid(row=1, column=0, sticky="ew", padx=5)
        self.tree_basket.configure(yscrollcommand=sb_basket_y.set, xscrollcommand=sb_basket_x.set)

        ctrl_basket = ttk.Frame(basket_frame)
        ctrl_basket.grid(row=2, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        ctrl_basket.grid_columnconfigure(3, weight=1)
        ttk.Button(ctrl_basket, text="Удалить выделенные", command=self.remove_selected_from_basket).grid(row=0, column=0, padx=5)
        ttk.Button(ctrl_basket, text="Очистить корзину", command=self.clear_basket).grid(row=0, column=1, padx=5)
        ttk.Button(ctrl_basket, text="Генерировать документ", command=self.generate_document).grid(row=0, column=2, padx=5)

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

        tab_set_gen.grid_columnconfigure(0, weight=1)
        tab_set_gen.grid_rowconfigure(6, weight=1)  # строка списка плейсхолдеров - тянется по высоте

        prof_frame = ttk.LabelFrame(tab_set_gen, text="Профили настроек")
        prof_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
        prof_frame.grid_columnconfigure(1, weight=1)

        self.combo_profiles = ttk.Combobox(prof_frame, state='readonly')
        self.combo_profiles.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
        self.combo_profiles.bind("<<ComboboxSelected>>", self.on_profile_change)

        btn_load_profile = ttk.Button(prof_frame, text="Загрузить профиль", command=self.load_profile)
        btn_load_profile.grid(row=0, column=1, padx=5)
        btn_save_profile = ttk.Button(prof_frame, text="Сохранить профиль", command=self.save_profile)
        btn_save_profile.grid(row=0, column=2, padx=5)
        btn_refresh_profiles = ttk.Button(prof_frame, text="Обновить список", command=self.load_profiles)
        btn_refresh_profiles.grid(row=0, column=3, padx=5)

        ttk.Label(tab_set_gen, text="Выбор шаблона:").grid(row=1, column=0, sticky='w', padx=10, pady=(0, 5))
        self.combo_templates = ttk.Combobox(tab_set_gen, state='readonly')
        self.combo_templates.grid(row=2, column=0, sticky="ew", padx=10)
        self.combo_templates.bind("<<ComboboxSelected>>", self.on_template_change)
        btn_preview_template = ttk.Button(tab_set_gen, text="Предпросмотр шаблона", command=self.preview_template)
        btn_preview_template.grid(row=3, column=0, sticky='w', padx=10, pady=5)

        ttk.Label(tab_set_gen, text="Плейсхолдеры:").grid(row=4, column=0, sticky='w', padx=10, pady=(5, 3))

        # Контейнер для поиска + списка с прокруткой по вертикали
        ph_container = ttk.Frame(tab_set_gen)
        ph_container.grid(row=5, column=0, sticky="nsew", padx=10)
        ph_container.grid_columnconfigure(0, weight=1)
        ph_container.grid_rowconfigure(1, weight=1)

        search_frame = ttk.Frame(ph_container)
        search_frame.grid(row=0, column=0, sticky="ew")
        search_frame.columnconfigure(1, weight=1)

        ttk.Label(search_frame, text="Поиск:").grid(row=0, column=0, sticky="w")
        self.var_placeholder_search = tk.StringVar()
        ent_ph_search = ttk.Entry(search_frame, textvariable=self.var_placeholder_search)
        ent_ph_search.grid(row=0, column=1, sticky="ew")
        ent_ph_search.bind("<KeyRelease>", self.on_placeholder_search)

        tree_frame = ttk.Frame(ph_container)
        tree_frame.grid(row=1, column=0, sticky="nsew", pady=(5, 0))
        tree_frame.grid_rowconfigure(0, weight=1)
        tree_frame.grid_columnconfigure(0, weight=1)

        self.tree_placeholders = ttk.Treeview(tree_frame, columns=("name", "value"), show='headings')
        self.tree_placeholders.grid(row=0, column=0, sticky="nsew")
        self.tree_placeholders.heading("name", text="Имя")
        self.tree_placeholders.heading("value", text="Значение")
        self.tree_placeholders.column("name", width=200, anchor="w")
        self.tree_placeholders.column("value", width=400, anchor="w")

        scrollbar_ph = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree_placeholders.yview)
        scrollbar_ph.grid(row=0, column=1, sticky="ns")
        self.tree_placeholders.configure(yscrollcommand=scrollbar_ph.set)

        ph_btn_frame = ttk.Frame(tab_set_gen)
        ph_btn_frame.grid(row=7, column=0, sticky="ew", padx=10, pady=5)
        for i, (text, cmd) in enumerate([
            ("Добавить", self.add_placeholder),
            ("Редактировать", self.edit_placeholder),
            ("Удалить", self.delete_placeholder),
            ("Импорт из JSON", self.import_placeholders),
            ("Экспорт в JSON", self.export_placeholders),
        ]):
            ttk.Button(ph_btn_frame, text=text, command=cmd).grid(row=0, column=i, padx=5)

        # Параметры таблицы
        style_label = ttk.Label(tab_set_gen, text="Параметры таблицы:")
        style_label.grid(row=8, column=0, sticky='w', padx=10, pady=(10, 3))
        style_frm = ttk.Frame(tab_set_gen)
        style_frm.grid(row=9, column=0, sticky="ew", padx=10, pady=5)

        style_frm.columnconfigure(10, weight=1)

        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)

        ttk.Label(style_frm, text="Стиль рамки:").grid(row=0, column=7, padx=5, pady=2, sticky='w')
        self.combo_border_style = ttk.Combobox(style_frm, values=['нет', 'тонкая', 'толстая'], state='readonly', width=10)
        self.combo_border_style.set(settings.data['table_style'].get('border_style', 'тонкая'))
        self.combo_border_style.grid(row=0, column=8, padx=5, pady=2)

        ttk.Label(style_frm, text="Шрифт заголовка раздела:").grid(row=1, column=0, sticky='w', padx=5, pady=3)
        self.entry_sec_header_font_name = ttk.Entry(style_frm, width=15)
        self.entry_sec_header_font_name.grid(row=1, column=1, padx=5)
        self.entry_sec_header_font_name.insert(0, settings.data['table_style'].get("section_header_font_name", "Calibri"))

        ttk.Label(style_frm, text="Размер шрифта:").grid(row=1, column=2, sticky='w', padx=5, pady=3)
        self.spin_sec_header_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_sec_header_font_size.grid(row=1, column=3, padx=5)
        self.spin_sec_header_font_size.delete(0, "end")
        self.spin_sec_header_font_size.insert(0, settings.data['table_style'].get("section_header_font_size", 12))

        self.var_sec_header_bold = tk.BooleanVar(value=settings.data['table_style'].get("section_header_bold", True))
        ttk.Checkbutton(style_frm, text="Жирный заголовок раздела", variable=self.var_sec_header_bold).grid(row=1, column=4, padx=5)

        ttk.Label(style_frm, text="Выравнивание заголовка:").grid(row=1, column=5, sticky='w', padx=5, pady=3)
        self.combo_sec_header_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_sec_header_align.grid(row=1, column=6, padx=5)
        self.combo_sec_header_align.set(settings.data['table_style'].get("section_header_align", "center"))

        ttk.Label(style_frm, text="Цвет фона заголовка (HEX):").grid(row=1, column=7, sticky='w', padx=5, pady=3)
        self.entry_sec_header_bg_color = ttk.Entry(style_frm, width=12)
        self.entry_sec_header_bg_color.grid(row=1, column=8, padx=5)
        self.entry_sec_header_bg_color.insert(0, settings.data['table_style'].get("section_header_bg_color", "FFFF00"))

        export_label = ttk.Label(tab_set_gen, text="Настройки экспорта:")
        export_label.grid(row=10, column=0, sticky='w', padx=10, pady=(10, 3))
        export_frm = ttk.Frame(tab_set_gen)
        export_frm.grid(row=11, column=0, sticky="ew", padx=10, pady=5)
        export_frm.grid_columnconfigure(8, weight=1)

        ttk.Label(export_frm, text="Формат файла:").grid(row=0, column=0, sticky="w", 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.grid(row=0, column=1, sticky="w", padx=5)

        self.var_compress_images = tk.BooleanVar(value=settings.data['export']['compress_images'])
        ttk.Checkbutton(export_frm, text="Сжимать изображения", variable=self.var_compress_images).grid(row=0, column=2, sticky="w", padx=10)

        ttk.Label(export_frm, text="Пароль для PDF:").grid(row=0, column=3, sticky="w", 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.grid(row=0, column=4, sticky="w", padx=5)

        ttk.Button(tab_set_gen, text="Сохранить настройки в профиль", command=self.save_profile).grid(row=12, column=0, sticky="e", padx=10, pady=10)

    def on_placeholder_search(self, event=None):
        query = self.var_placeholder_search.get().strip().lower()
        filtered = {}

        for k, v in self.user_placeholders.items():
            if query in k.lower():
                filtered[k] = v

        self.refresh_placeholders_view(filtered)

    def refresh_placeholders_view(self, placeholders_dict=None):
        if placeholders_dict is None:
            placeholders_dict = self.user_placeholders

        self.tree_placeholders.delete(*self.tree_placeholders.get_children())
        for k in sorted(placeholders_dict.keys(), key=lambda s: s.lower()):
            self.tree_placeholders.insert("", "end", values=(k, placeholders_dict[k]))

    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

        ts["border_style"] = self.combo_border_style.get() if hasattr(self, "combo_border_style") else "тонкая"

        ts["section_header_font_name"] = self.entry_sec_header_font_name.get() if hasattr(self, "entry_sec_header_font_name") else "Calibri"
        try:
            ts["section_header_font_size"] = int(self.spin_sec_header_font_size.get()) if hasattr(self, "spin_sec_header_font_size") else 12
        except:
            ts["section_header_font_size"] = 12
        ts["section_header_bold"] = self.var_sec_header_bold.get() if hasattr(self, "var_sec_header_bold") else True
        ts["section_header_align"] = self.combo_sec_header_align.get() if hasattr(self, "combo_sec_header_align") else "center"
        ts["section_header_bg_color"] = self.entry_sec_header_bg_color.get() if hasattr(self, "entry_sec_header_bg_color") else "FFFF00"

        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_border_style"):
            self.combo_border_style.set(ts.get("border_style", "тонкая"))

        if hasattr(self, "entry_sec_header_font_name"):
            self.entry_sec_header_font_name.delete(0, "end")
            self.entry_sec_header_font_name.insert(0, ts.get("section_header_font_name", "Calibri"))
        if hasattr(self, "spin_sec_header_font_size"):
            self.spin_sec_header_font_size.delete(0, "end")
            self.spin_sec_header_font_size.insert(0, ts.get("section_header_font_size", 12))
        if hasattr(self, "var_sec_header_bold"):
            self.var_sec_header_bold.set(ts.get("section_header_bold", True))
        if hasattr(self, "combo_sec_header_align"):
            self.combo_sec_header_align.set(ts.get("section_header_align", "center"))
        if hasattr(self, "entry_sec_header_bg_color"):
            self.entry_sec_header_bg_color.delete(0, "end")
            self.entry_sec_header_bg_color.insert(0, ts.get("section_header_bg_color", "FFFF00"))

        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()

        save_dir = None
        if path is None:
            save_dir = filedialog.askdirectory(title="Выберите папку для сохранения документов")
            if not save_dir:
                return
        else:
            save_dir = os.path.dirname(path)

        try:
            template_path = os.path.join(TEMPLATES_DIR, self.current_template)
            phs_global = self.user_placeholders.copy()
            unique_arts = list(dict.fromkeys(self.basket_df["Артикул"]))

            for art in unique_arts:
                doc = Document(template_path)

                if art in self.grouped_data:
                    info = self.grouped_data[art]
                    art_info = {
                        "Объект": info["object_full"],
                        "Тип товара": info["product_type"],
                        "Артикул": art,
                    }
                    art_sections = info["sections"]
                else:
                    art_info = {"Объект": "", "Тип товара": "", "Артикул": art}
                    art_sections = []

                phs = dict(phs_global)
                phs.update(art_info)

                for section in art_sections:
                    for param in section["params"]:
                        pname = param["parameter"]
                        pvalue = param["value"]
                        if pname in phs and phs[pname] == "{{" + pname + "}}":
                            phs[pname] = pvalue

                for k, v in phs.items():
                    docx_replace_placeholder(doc, k, v)

                placeholder_paragraph = None
                for p in doc.paragraphs:
                    if "{{Таблица}}" in p.text:
                        placeholder_paragraph = p
                        break

                if placeholder_paragraph is None:
                    logger.log(f"Плейсхолдер {{Таблица}} не найден в шаблоне для артикула {art}")
                    if show_messages:
                        messagebox.showwarning("Генерация",
                                               f"Шаблон не содержит плейсхолдера {{Таблица}} для артикула {art}")
                    filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                    file_path = os.path.join(save_dir, filename)
                    doc.save(file_path)
                    continue

                style = settings.data.get("table_style", {})
                border_style = style.get("border_style", "тонкая")

                previous_element = placeholder_paragraph._element

                sh_font_name = style.get("section_header_font_name", "Calibri")
                sh_font_size = style.get("section_header_font_size", 12)
                sh_bold = style.get("section_header_bold", True)
                sh_align = style.get("section_header_align", "center")
                sh_bg_color = style.get("section_header_bg_color", "FFFF00")

                for section in art_sections:
                    tbl = doc.add_table(rows=1, cols=2)
                    set_table_border(tbl, border_style)

                    row = tbl.rows[0]
                    cell = row.cells[0]
                    cell.merge(row.cells[1])
                    cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER

                    paragraph = cell.paragraphs[0]
                    paragraph.alignment = {
                        "left": WD_ALIGN_PARAGRAPH.LEFT,
                        "center": WD_ALIGN_PARAGRAPH.CENTER,
                        "right": WD_ALIGN_PARAGRAPH.RIGHT,
                    }.get(sh_align, WD_ALIGN_PARAGRAPH.CENTER)
                    for r in paragraph.runs:
                        r.text = ''
                    run = paragraph.add_run(section.get("header", ""))
                    run.font.name = sh_font_name
                    run.font.size = Pt(sh_font_size)
                    run.bold = sh_bold
                    set_cell_background(cell, sh_bg_color)

                    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]
                            p_cell.style = None
                            for r in p_cell.runs:
                                r.font.name = style.get("font_name", "Calibri")
                                r.font.size = Pt(style.get("font_size", 11))
                                r.bold = False
                            p_cell.alignment = {
                                "left": WD_ALIGN_PARAGRAPH.LEFT,
                                "right": WD_ALIGN_PARAGRAPH.RIGHT,
                                "center": WD_ALIGN_PARAGRAPH.CENTER,
                            }.get(style.get("alignment", "center"), WD_ALIGN_PARAGRAPH.CENTER)

                    previous_element.addnext(tbl._element)
                    previous_element = tbl._element

                p = placeholder_paragraph._element
                p.getparent().remove(p)

                filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                file_path = os.path.join(save_dir, filename)
                doc.save(file_path)
                logger.log(f"Создан файл для артикула {art}: {file_path}")

                if settings.data.get("export", {}).get("format") == "pdf":
                    pdf_path = os.path.splitext(file_path)[0] + ".pdf"
                    if WIN32COM_AVAILABLE:
                        convert_docx_to_pdf(file_path, pdf_path)
                        logger.log(f"PDF создан для артикула {art}: {pdf_path}")
                    else:
                        logger.log("Для создания PDF необходим MS Word и pywin32")

            if show_messages:
                messagebox.showinfo("Успех", f"Обработано товаров: {len(unique_arts)}\nФайлы сохранены в:\n{save_dir}")

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

    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)

            param_dict = {}
            for info in self.grouped_data.values():
                for section in info['sections']:
                    for param in section['params']:
                        pname, pval = param['parameter'], param['value']
                        if pname not in param_dict:
                            param_dict[pname] = set()
                        param_dict[pname].add(pval)
            self.all_params = sorted(param_dict.keys())
            self.param_values = dict((k, sorted(v)) for k, v in param_dict.items())

            for row in self.param_filter_rows:
                row['param']['values'] = self.all_params
                p = row['param'].get()
                if p and p in self.param_values:
                    row['value']['values'] = [''] + self.param_values[p]
                else:
                    row['value']['values'] = []
                row['value'].set('')

            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.apply_all_filters()

            self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])
            self.update_basket_table()

            self.create_placeholders_from_loaded_products()

            messagebox.showinfo("Успех", f"Данные загружены: {len(self.filtered_df_backup)} записей")
            logger.log(f"Файл загружен: {path}, записей: {len(self.filtered_df_backup)}")
        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):
        self.apply_all_filters()

    def show_item_params(self, event=None):
        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:
            if self.grouped_data[art]["sections"]:
                params = self.grouped_data[art]["sections"][0]["params"]
            else:
                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.basket_df = pd.concat([self.basket_df, df_add], ignore_index=True)
        self.basket_df.drop_duplicates(inplace=True)
        self.basket_df.reset_index(drop=True, inplace=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.update_basket_table()
            logger.log("Корзина очищена")

    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 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 add_param_filter_row(self):
        row_frame = ttk.Frame(self.param_filters_container)
        row_frame.pack(fill="x", pady=2)

        param_combo = ttk.Combobox(row_frame, state="readonly", width=25)
        param_combo['values'] = self.all_params
        param_combo.pack(side='left', padx=2)
        param_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, rf=row_frame: self.on_param_combo_change(c, rf))

        value_combo = ttk.Combobox(row_frame, state="readonly", width=25)
        value_combo.pack(side='left', padx=2)
        value_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, v=value_combo: self.on_param_value_change(c, v))

        del_btn = ttk.Button(row_frame, text="✖", width=3,
                             command=lambda rf=row_frame: self.remove_param_filter_row(rf))
        del_btn.pack(side='left', padx=2)

        self.param_filter_rows.append({'frame': row_frame, 'param': param_combo, 'value': value_combo, 'del_btn': del_btn})

    def remove_param_filter_row(self, row_frame):
        self.param_filter_rows = [r for r in self.param_filter_rows if r['frame'] != row_frame]
        row_frame.destroy()
        self.apply_all_filters()

    def on_param_combo_change(self, param_combo, row_frame):
        param = param_combo.get()
        value_combo = None
        for row in self.param_filter_rows:
            if row['frame'] == row_frame:
                value_combo = row['value']
                break
        if value_combo:
            values = []
            if param and param in self.param_values:
                values = sorted(self.param_values[param])
            value_combo['values'] = [''] + values
            value_combo.set('')
        self.apply_all_filters()

    def on_param_value_change(self, param_combo, value_combo):
        self.apply_all_filters()

    def collect_param_filters(self):
        """
        Считывает все выбранные фильтры параметров из интерфейса,
        возвращает dict параметр: множество значений для фильтра; фильтры без значения игнорируются
        """
        res = {}
        for row in self.param_filter_rows:
            p = row['param'].get()
            v = row['value'].get()
            if p and v:
                res.setdefault(p, set()).add(v)
        return res

    def apply_all_filters(self):
        """
        Применяет все фильтры к исходным данным:
        - быстрый поиск по артикулу
        - фильтрация по параметрам (каждый параметр — набор возможных значений)
        """
        if self.filtered_df_backup.empty:
            return

        df = self.filtered_df_backup.copy()

        # Фильтр по артикулу (ввод в строку поиска)
        q = self.var_quick_search.get().lower().strip()
        if q:
            df = df[df['Артикул'].str.lower().str.contains(q, na=False)]

        param_filters = self.collect_param_filters()
        if param_filters:
            filtered_arts = []
            for art in df['Артикул']:
                info = self.grouped_data.get(art)
                if not info:
                    continue
                all_match = True
                for p, vals in param_filters.items():
                    match = False
                    for section in info['sections']:
                        for param_dict in section['params']:
                            if param_dict['parameter'] == p and param_dict['value'] in vals:
                                match = True
                                break
                        if match:
                            break
                    if not match:
                        all_match = False
                        break
                if all_match:
                    filtered_arts.append(art)
            df = df[df['Артикул'].isin(filtered_arts)]

        self.filtered_df = df
        self.update_data_table()


def convert_docx_to_pdf(docx_path, pdf_path):
    word = win32com.client.Dispatch("Word.Application")
    word.Visible = False
    doc = word.Documents.Open(docx_path)
    doc.SaveAs(pdf_path, FileFormat=17)
    doc.Close()
    word.Quit()


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


if __name__ == '__main__':
    main()

или

import os
import json
import traceback
from datetime import datetime
import subprocess
import threading

import pandas as pd
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.oxml.ns import qn, nsdecls
from docx.oxml import OxmlElement, parse_xml

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, ui_log_callback=None):
        self.history = []
        self.ui_log_callback = ui_log_callback

    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 self.ui_log_callback:
            self.ui_log_callback(record)

    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}")


class Settings:
    def __init__(self, logger=None):
        self.logger = logger
        self.data = {
            "table_style": {
                "header_bold": True,
                "alignment": "center",
                "font_name": "Calibri",
                "font_size": 11,
                "border_style": "тонкая",
                "section_header_font_name": "Calibri",
                "section_header_font_size": 12,
                "section_header_bold": True,
                "section_header_align": "center",
                "section_header_bg_color": "FFFF00"
            },
            "export": {
                "format": "docx",
                "pdf_password": "",
                "compress_images": True,
                "template": None
            }
        }
        self.load()

    def _apply_defaults(self):
        exp = self.data.setdefault('export', {})
        exp.setdefault('format', 'docx')
        exp.setdefault('pdf_password', '')
        exp.setdefault('compress_images', True)

        ts = self.data.setdefault('table_style', {})
        defaults = {
            "border_style": "тонкая",
            "section_header_font_name": "Calibri",
            "section_header_font_size": 12,
            "section_header_bold": True,
            "section_header_align": "center",
            "section_header_bg_color": "FFFF00"
        }
        for k, v in defaults.items():
            ts.setdefault(k, v)

    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)
                self._apply_defaults()
                if self.logger:
                    self.logger.log("Настройки загружены")
            except Exception as e:
                if self.logger:
                    self.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)
            if self.logger:
                self.logger.log("Настройки сохранены")
        except Exception as e:
            if self.logger:
                self.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)
            if self.logger:
                self.logger.log(f"Профиль сохранён: {filename}")
            return True
        except Exception as e:
            if self.logger:
                self.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)
            self._apply_defaults()
            if self.logger:
                self.logger.log(f"Профиль загружен: {filename}")
            return True
        except Exception as e:
            if self.logger:
                self.logger.log(f"Ошибка загрузки профиля: {e}")
            return False


class Placeholders:
    def __init__(self, logger=None):
        self.placeholders = {}
        self.logger = logger
        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)
                if self.logger:
                    self.logger.log("Плейсхолдеры загружены")
            except Exception as e:
                if self.logger:
                    self.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)
            if self.logger:
                self.logger.log("Плейсхолдеры сохранены")
        except Exception as e:
            if self.logger:
                self.logger.log(f"Ошибка сохранения плейсхолдеров: {e}")


def split_by_idis(obj):
    if not isinstance(obj, str) or not obj.strip():
        return "", ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        return obj[:idx].strip(), obj[idx + 4:].strip()
    return obj, ""


def extract_article_full(obj):
    _, article = split_by_idis(obj)
    if article:
        return article
    words = obj.strip().split()
    if len(words) <= 2:
        return obj.strip()
    return " ".join(words[-3:]).strip()


def extract_product_type(obj):
    product_type, _ = split_by_idis(obj)
    if product_type:
        return product_type
    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):
    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}
    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))
    replace_placeholder_in_header_footer(doc, key, value)


def replace_placeholder_in_header_footer(doc, key, value):
    ph = "{{" + key + "}}"
    for section in doc.sections:
        header = section.header
        for p in header.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in header.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))
        footer = section.footer
        for p in footer.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in footer.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 set_table_border(table, border_style):
    borders_templates = {
        'нет': """
            <w:tblBorders %s>
                <w:top w:val="nil"/>
                <w:left w:val="nil"/>
                <w:bottom w:val="nil"/>
                <w:right w:val="nil"/>
                <w:insideH w:val="nil"/>
                <w:insideV w:val="nil"/>
            </w:tblBorders>
        """,
        'тонкая': """
            <w:tblBorders %s>
                <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            </w:tblBorders>
        """,
        'толстая': """
            <w:tblBorders %s>
                <w:top w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:left w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:bottom w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:right w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:insideH w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:insideV w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            </w:tblBorders>
        """,
    }

    border_xml = borders_templates.get(border_style.lower(), borders_templates['тонкая']) % nsdecls('w')
    tblPr = table._element.tblPr

    for child in tblPr.findall("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tblBorders"):
        tblPr.remove(child)

    borders = parse_xml(border_xml)
    tblPr.append(borders)


def set_cell_background(cell, hex_color):
    tc = cell._tc
    tcPr = tc.get_or_add_tcPr()
    shd = OxmlElement('w:shd')
    shd.set(qn('w:fill'), hex_color)
    tcPr.append(shd)


def convert_docx_to_pdf(docx_path, pdf_path):
    if not WIN32COM_AVAILABLE:
        raise RuntimeError("Для создания PDF необходим MS Word и pywin32")
    word = win32com.client.Dispatch("Word.Application")
    word.Visible = False
    doc = word.Documents.Open(docx_path)
    doc.SaveAs(pdf_path, FileFormat=17)
    doc.Close()
    word.Quit()


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Автоматизация техдокументации")
        self.geometry("1200x720")
        self.minsize(1000, 600)

        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)

        self.logger = Logger(ui_log_callback=self.append_log_to_text_widget)
        self.settings = Settings(logger=self.logger)
        self.placeholders = Placeholders(logger=self.logger)

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

        self.all_params = []
        self.param_values = {}

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

        self.param_filter_rows = []

        self.var_quick_search = tk.StringVar()
        self.var_placeholder_search = tk.StringVar()
        self.var_header_bold = tk.BooleanVar()
        self.var_compress_images = tk.BooleanVar()
        self.var_sec_header_bold = tk.BooleanVar()

        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()
        self.create_placeholders_from_loaded_products()

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

    def append_log_to_text_widget(self, record):
        # Реализация вывода лога в UI (можно добавить виджет Text для логов)
        pass

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

    def refresh_placeholders_view(self, placeholders_dict=None):
        if placeholders_dict is None:
            placeholders_dict = self.user_placeholders

        self.tree_placeholders.delete(*self.tree_placeholders.get_children())
        for k in sorted(placeholders_dict.keys(), key=lambda s: s.lower()):
            self.tree_placeholders.insert("", "end", values=(k, placeholders_dict[k]))

    def on_placeholder_search(self, event=None):
        query = self.var_placeholder_search.get().strip().lower()
        filtered = {}
        for k, v in self.user_placeholders.items():
            if query in k.lower():
                filtered[k] = v
        self.refresh_placeholders_view(filtered)

    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
        self.placeholders.placeholders = self.user_placeholders.copy()
        self.placeholders.save()
        self.refresh_placeholders_view()
        self.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
        self.placeholders.placeholders = self.user_placeholders.copy()
        self.placeholders.save()
        self.refresh_placeholders_view()
        self.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)
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            self.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)
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            messagebox.showinfo("Импорт", f"Импортировано {len(d)} плейсхолдеров")
            self.logger.log(f"Импортировано плейсхолдеров: {len(d)}")
        except Exception as e:
            messagebox.showerror("Ошибка импорта", str(e))
            self.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}")
            self.logger.log(f"Плейсхолдеры экспортированы: {path}")
        except Exception as e:
            messagebox.showerror("Ошибка экспорта", str(e))
            self.logger.log(f"Ошибка экспорта плейсхолдеров: {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 self.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 self.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 self.settings.load_profile(selected):
                self.load_settings_to_gui()
                messagebox.showinfo("Профиль", f"Профиль '{selected}' загружен")
            else:
                messagebox.showerror("Ошибка", f"Ошибка загрузки профиля: {selected}")

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

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

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

    def add_param_filter_row(self):
        row_frame = ttk.Frame(self.param_filters_container)
        row_frame.pack(fill="x", pady=2)

        param_combo = ttk.Combobox(row_frame, state="readonly", width=25, postcommand=lambda: param_combo.configure(values=self.all_params))
        param_combo.pack(side='left', padx=2)
        param_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, rf=row_frame: self.on_param_combo_change(c, rf))

        value_combo = ttk.Combobox(row_frame, state="readonly", width=25)
        value_combo.pack(side='left', padx=2)
        value_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, v=value_combo: self.on_param_value_change(c, v))

        del_btn = ttk.Button(row_frame, text="✖", width=3,
                             command=lambda rf=row_frame: self.remove_param_filter_row(rf))
        del_btn.pack(side='left', padx=2)

        self.param_filter_rows.append({'frame': row_frame, 'param': param_combo, 'value': value_combo, 'del_btn': del_btn})

    def remove_param_filter_row(self, row_frame):
        self.param_filter_rows = [r for r in self.param_filter_rows if r['frame'] != row_frame]
        row_frame.destroy()
        self.apply_all_filters()

    def on_param_combo_change(self, param_combo, row_frame):
        param = param_combo.get()
        value_combo = None
        for row in self.param_filter_rows:
            if row['frame'] == row_frame:
                value_combo = row['value']
                break
        if value_combo:
            values = []
            if param and param in self.param_values:
                values = sorted(self.param_values[param])
            value_combo['values'] = [''] + values
            value_combo.set('')
        self.apply_all_filters()

    def on_param_value_change(self, param_combo, value_combo):
        self.apply_all_filters()

    def collect_param_filters(self):
        res = {}
        for row in self.param_filter_rows:
            p = row['param'].get()
            v = row['value'].get()
            if p and v:
                res.setdefault(p, set()).add(v)
        return res

    def quick_search(self, event=None):
        self.apply_all_filters()

    def apply_all_filters(self):
        if self.filtered_df_backup.empty:
            return

        df = self.filtered_df_backup.copy()

        q = self.var_quick_search.get().lower().strip()
        if q:
            df = df[df['Артикул'].str.lower().str.contains(q, na=False)]

        param_filters = self.collect_param_filters()
        if param_filters:
            filtered_arts = []
            for art in df['Артикул']:
                info = self.grouped_data.get(art)
                if not info:
                    continue
                all_match = True
                for p, vals in param_filters.items():
                    match = any(
                        param_dict['parameter'] == p and param_dict['value'] in vals
                        for section in info['sections']
                        for param_dict in section['params']
                    )
                    if not match:
                        all_match = False
                        break
                if all_match:
                    filtered_arts.append(art)
            df = df[df['Артикул'].isin(filtered_arts)]

        self.filtered_df = df
        self.update_data_table()

    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 show_item_params(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            return
        iid = sel[0]
        item = self.tree_data.item(iid)
        if not item or not item.get("values"):
            return
        art = item["values"][2]
        if art in self.grouped_data:
            if self.grouped_data[art]["sections"]:
                params = self.grouped_data[art]["sections"][0]["params"]
            else:
                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.basket_df = pd.concat([self.basket_df, df_add], ignore_index=True)
        self.basket_df.drop_duplicates(inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        self.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()
        self.logger.log(f"Удалено из корзины: {len(idxs)} записей")

    def clear_basket(self):
        if messagebox.askyesno("Очистка корзины", "Очистить корзину?"):
            self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])
            self.update_basket_table()
            self.logger.log("Корзина очищена")

    def save_gui_to_settings(self):
        d = self.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

        ts["border_style"] = self.combo_border_style.get() if hasattr(self, "combo_border_style") else "тонкая"

        ts["section_header_font_name"] = self.entry_sec_header_font_name.get() if hasattr(self, "entry_sec_header_font_name") else "Calibri"
        try:
            ts["section_header_font_size"] = int(self.spin_sec_header_font_size.get()) if hasattr(self, "spin_sec_header_font_size") else 12
        except:
            ts["section_header_font_size"] = 12
        ts["section_header_bold"] = self.var_sec_header_bold.get() if hasattr(self, "var_sec_header_bold") else True
        ts["section_header_align"] = self.combo_sec_header_align.get() if hasattr(self, "combo_sec_header_align") else "center"
        ts["section_header_bg_color"] = self.entry_sec_header_bg_color.get() if hasattr(self, "entry_sec_header_bg_color") else "FFFF00"

        self.settings.save()

    def load_settings_to_gui(self):
        d = self.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_border_style"):
            self.combo_border_style.set(ts.get("border_style", "тонкая"))

        if hasattr(self, "entry_sec_header_font_name"):
            self.entry_sec_header_font_name.delete(0, "end")
            self.entry_sec_header_font_name.insert(0, ts.get("section_header_font_name", "Calibri"))
        if hasattr(self, "spin_sec_header_font_size"):
            self.spin_sec_header_font_size.delete(0, "end")
            self.spin_sec_header_font_size.insert(0, ts.get("section_header_font_size", 12))
        if hasattr(self, "var_sec_header_bold"):
            self.var_sec_header_bold.set(ts.get("section_header_bold", True))
        if hasattr(self, "combo_sec_header_align"):
            self.combo_sec_header_align.set(ts.get("section_header_align", "center"))
        if hasattr(self, "entry_sec_header_bg_color"):
            self.entry_sec_header_bg_color.delete(0, "end")
            self.entry_sec_header_bg_color.insert(0, ts.get("section_header_bg_color", "FFFF00"))

        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_excel_dialog(self):
        path = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel Files", "*.xlsx *.xls")])
        if not path:
            return

        def task():
            try:
                self.logger.log(f"Загрузка файла: {path}")
                self.grouped_data = load_and_transform_excel_grouped(path)

                param_dict = {}
                for info in self.grouped_data.values():
                    for section in info['sections']:
                        for param in section['params']:
                            pname, pval = param['parameter'], param['value']
                            if pname not in param_dict:
                                param_dict[pname] = set()
                            param_dict[pname].add(pval)
                self.all_params = sorted(param_dict.keys())
                self.param_values = dict((k, sorted(v)) for k, v in param_dict.items())

                for row in self.param_filter_rows:
                    row['param']['values'] = self.all_params
                    p = row['param'].get()
                    if p and p in self.param_values:
                        row['value']['values'] = [''] + self.param_values[p]
                    else:
                        row['value']['values'] = []
                    row['value'].set('')

                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.apply_all_filters()

                self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])

                self.after(0, self.update_basket_table)
                self.create_placeholders_from_loaded_products()

                self.after(0, lambda: messagebox.showinfo("Успех", f"Данные загружены: {len(self.filtered_df_backup)} записей"))
                self.logger.log(f"Файл загружен: {path}, записей: {len(self.filtered_df_backup)}")
            except Exception as e:
                self.after(0, lambda: messagebox.showerror("Ошибка загрузки", str(e)))
                self.logger.log(f"Ошибка загрузки: {e}")
                traceback.print_exc()

        threading.Thread(target=task, daemon=True).start()

    def create_placeholders_from_loaded_products(self):
        if not self.grouped_data:
            self.logger.log("Данные товаров отсутствуют - автоматическое создание плейсхолдеров пропущено")
            return

        unique_params = set()
        for info in self.grouped_data.values():
            for section in info['sections']:
                for param in section['params']:
                    unique_params.add(param['parameter'])

        updated = False
        for param in unique_params:
            if param not in self.user_placeholders:
                self.user_placeholders[param] = "{{" + param + "}}"
                updated = True

        if updated:
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            self.logger.log(f"Автоматически создано плейсхолдеров из параметров: {len(unique_params)}")
        else:
            self.logger.log("Автоматическое создание плейсхолдеров: новых плейсхолдеров не добавлено")

    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 path is None:
            save_dir = filedialog.askdirectory(title="Выберите папку для сохранения документов")
            if not save_dir:
                return
        else:
            save_dir = os.path.dirname(path)

        def task():
            try:
                template_path = os.path.join(TEMPLATES_DIR, self.current_template)
                phs_global = self.user_placeholders.copy()
                unique_arts = list(dict.fromkeys(self.basket_df["Артикул"]))

                for art in unique_arts:
                    doc = Document(template_path)

                    if art in self.grouped_data:
                        info = self.grouped_data[art]
                        art_info = {
                            "Объект": info["object_full"],
                            "Тип товара": info["product_type"],
                            "Артикул": art,
                        }
                        art_sections = info["sections"]
                    else:
                        art_info = {"Объект": "", "Тип товара": "", "Артикул": art}
                        art_sections = []

                    phs = dict(phs_global)
                    phs.update(art_info)

                    for section in art_sections:
                        for param in section["params"]:
                            pname = param["parameter"]
                            pvalue = param["value"]
                            if pname in phs and phs[pname] == "{{" + pname + "}}":
                                phs[pname] = pvalue

                    for k, v in phs.items():
                        docx_replace_placeholder(doc, k, v)

                    placeholder_paragraph = None
                    for p in doc.paragraphs:
                        if "{{Таблица}}" in p.text:
                            placeholder_paragraph = p
                            break

                    if not placeholder_paragraph:
                        self.logger.log(f"Плейсхолдер {{Таблица}} не найден в шаблоне для артикула {art}")
                        if show_messages:
                            self.after(0, lambda art=art: messagebox.showwarning(
                                "Генерация", f"Шаблон не содержит плейсхолдера {{Таблица}} для артикула {art}"))
                        filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                        file_path = os.path.join(save_dir, filename)
                        doc.save(file_path)
                        continue

                    style = self.settings.data.get("table_style", {})
                    border_style = style.get("border_style", "тонкая")

                    previous_element = placeholder_paragraph._element

                    sh_font_name = style.get("section_header_font_name", "Calibri")
                    sh_font_size = style.get("section_header_font_size", 12)
                    sh_bold = style.get("section_header_bold", True)
                    sh_align = style.get("section_header_align", "center")
                    sh_bg_color = style.get("section_header_bg_color", "FFFF00")

                    for section in art_sections:
                        tbl = doc.add_table(rows=1, cols=2)
                        set_table_border(tbl, border_style)

                        row = tbl.rows[0]
                        cell = row.cells[0]
                        cell.merge(row.cells[1])
                        cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER

                        paragraph = cell.paragraphs[0]
                        paragraph.alignment = {
                            "left": WD_ALIGN_PARAGRAPH.LEFT,
                            "center": WD_ALIGN_PARAGRAPH.CENTER,
                            "right": WD_ALIGN_PARAGRAPH.RIGHT,
                        }.get(sh_align, WD_ALIGN_PARAGRAPH.CENTER)
                        for r in paragraph.runs:
                            r.text = ''
                        run = paragraph.add_run(section.get("header", ""))
                        run.font.name = sh_font_name
                        run.font.size = Pt(sh_font_size)
                        run.bold = sh_bold
                        set_cell_background(cell, sh_bg_color)

                        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]
                                p_cell.style = None
                                for r in p_cell.runs:
                                    r.font.name = style.get("font_name", "Calibri")
                                    r.font.size = Pt(style.get("font_size", 11))
                                    r.bold = False
                                p_cell.alignment = {
                                    "left": WD_ALIGN_PARAGRAPH.LEFT,
                                    "right": WD_ALIGN_PARAGRAPH.RIGHT,
                                    "center": WD_ALIGN_PARAGRAPH.CENTER,
                                }.get(style.get("alignment", "center"), WD_ALIGN_PARAGRAPH.CENTER)

                        previous_element.addnext(tbl._element)
                        previous_element = tbl._element

                    p = placeholder_paragraph._element
                    p.getparent().remove(p)

                    filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                    file_path = os.path.join(save_dir, filename)
                    doc.save(file_path)
                    self.logger.log(f"Создан файл для артикула {art}: {file_path}")

                    if self.settings.data.get("export", {}).get("format") == "pdf":
                        pdf_path = os.path.splitext(file_path)[0] + ".pdf"
                        try:
                            convert_docx_to_pdf(file_path, pdf_path)
                            self.logger.log(f"PDF создан для артикула {art}: {pdf_path}")
                        except Exception as e:
                            self.logger.log(f"Ошибка при создании PDF для артикула {art}: {e}")

                if show_messages:
                    self.after(0, lambda: messagebox.showinfo(
                        "Успех", f"Обработано товаров: {len(unique_arts)}\nФайлы сохранены в:\n{save_dir}"))

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

        threading.Thread(target=task, daemon=True).start()

    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.tk_popup(event.x_root, event.y_root)
        menu.grab_release()

    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.tk_popup(event.x_root, event.y_root)
        menu.grab_release()

    def create_menu(self):
        menubar = tk.Menu(self)

        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="Загрузить Excel", command=self.load_excel_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="Сохранить лог...", command=self.export_log)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.quit)
        menubar.add_cascade(label="Файл", menu=file_menu)

        basket_menu = tk.Menu(menubar, tearoff=0)
        basket_menu.add_command(label="Удалить выделенные", command=self.remove_selected_from_basket)
        basket_menu.add_command(label="Очистить корзину", command=self.clear_basket)
        menubar.add_cascade(label="Корзина", menu=basket_menu)

        help_menu = tk.Menu(menubar, tearoff=0)
        help_menu.add_command(label="О программе", command=self.show_about)
        menubar.add_cascade(label="Помощь", menu=help_menu)

        self.config(menu=menubar)

    def bind_shortcuts(self):
        pass  # Можно добавить горячие клавиши

    def create_widgets(self):
        self.nb = ttk.Notebook(self)
        self.nb.grid(row=0, column=0, sticky="nsew")

        self.create_tab_data()
        self.create_tab_settings_gen()
        self.create_basket_frame()

    def create_tab_data(self):
        tab_data = ttk.Frame(self.nb)
        self.nb.add(tab_data, text="Данные")

        tab_data.grid_rowconfigure(1, weight=1)
        tab_data.grid_columnconfigure(0, weight=1)
        tab_data.grid_columnconfigure(1, weight=0)

        filter_frame = ttk.Frame(tab_data)
        filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        filter_frame.grid_columnconfigure(7, weight=1)

        ttk.Label(filter_frame, text="Поиск по артикулу:").grid(row=0, column=0, sticky="w")
        ent_quick_search = ttk.Entry(filter_frame, textvariable=self.var_quick_search)
        ent_quick_search.grid(row=0, column=1, sticky="ew", padx=3)
        filter_frame.grid_columnconfigure(1, weight=2)
        ent_quick_search.bind("<KeyRelease>", self.quick_search)

        ttk.Label(filter_frame, text="Фильтры параметров:").grid(row=0, column=2, sticky="w", padx=10)

        self.btn_add_param_filter = ttk.Button(filter_frame, text="+ Фильтр", command=self.add_param_filter_row)
        self.btn_add_param_filter.grid(row=0, column=7, sticky="e")

        self.param_filters_container = ttk.Frame(filter_frame)
        self.param_filters_container.grid(row=1, column=0, columnspan=8, sticky="ew", pady=5)
        self.param_filters_container.grid_columnconfigure(0, weight=1)

        self.param_filter_rows = []

        tbl_frame = ttk.Frame(tab_data)
        tbl_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        tbl_frame.grid_rowconfigure(0, weight=1)
        tbl_frame.grid_columnconfigure(0, weight=1)

        self.tree_data = ttk.Treeview(tbl_frame, show="headings", selectmode="extended")
        self.tree_data.grid(row=0, column=0, sticky="nsew")
        self.tree_data.bind("<Double-1>", self.add_selected_to_basket)
        self.tree_data.bind("<<TreeviewSelect>>", 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.grid(row=0, column=1, sticky="ns")

        scr_x = ttk.Scrollbar(tbl_frame, orient="horizontal", command=self.tree_data.xview)
        scr_x.grid(row=1, column=0, sticky="ew")

        self.tree_data.configure(yscrollcommand=scr_y.set, xscrollcommand=scr_x.set)

        right_frame = ttk.Frame(tab_data, relief="groove", borderwidth=2)
        right_frame.grid(row=1, column=1, sticky="nsew", padx=5, pady=5)
        tab_data.grid_columnconfigure(1, weight=0)
        right_frame.grid_rowconfigure(1, weight=1)
        right_frame.grid_columnconfigure(0, weight=1)

        ttk.Label(right_frame, text="Параметры выбранного товара:").grid(row=0, column=0, sticky="w", padx=5, pady=(0, 5))
        self.tree_params = ttk.Treeview(right_frame, show="headings", columns=["Параметр", "Значение"], height=20)
        self.tree_params.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        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")

        sb_params_y = ttk.Scrollbar(right_frame, orient="vertical", command=self.tree_params.yview)
        sb_params_y.grid(row=1, column=1, sticky="ns")
        self.tree_params.configure(yscrollcommand=sb_params_y.set)

    def create_basket_frame(self):
        basket_frame = ttk.LabelFrame(self, text="Корзина товаров")
        basket_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        self.grid_rowconfigure(1, weight=1)

        basket_frame.grid_rowconfigure(0, weight=1)
        basket_frame.grid_columnconfigure(0, weight=1)

        self.tree_basket = ttk.Treeview(basket_frame, show="headings", selectmode="extended", height=6)
        self.tree_basket.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
        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.grid(row=0, column=1, sticky="ns", pady=5)

        sb_basket_x = ttk.Scrollbar(basket_frame, orient="horizontal", command=self.tree_basket.xview)
        sb_basket_x.grid(row=1, column=0, sticky="ew", padx=5)

        self.tree_basket.configure(yscrollcommand=sb_basket_y.set, xscrollcommand=sb_basket_x.set)

        ctrl_basket = ttk.Frame(basket_frame)
        ctrl_basket.grid(row=2, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        ctrl_basket.grid_columnconfigure(3, weight=1)

        ttk.Button(ctrl_basket, text="Удалить выделенные", command=self.remove_selected_from_basket).grid(row=0, column=0, padx=5)
        ttk.Button(ctrl_basket, text="Очистить корзину", command=self.clear_basket).grid(row=0, column=1, padx=5)
        ttk.Button(ctrl_basket, text="Генерировать документ", command=self.generate_document).grid(row=0, column=2, padx=5)

    def create_tab_settings_gen(self):
        tab = ttk.Frame(self.nb)
        self.nb.add(tab, text="Настройки и Генерация")

        tab.grid_columnconfigure(0, weight=1)
        tab.grid_rowconfigure(6, weight=1)

        prof_frame = ttk.LabelFrame(tab, text="Профили настроек")
        prof_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
        prof_frame.grid_columnconfigure(1, weight=1)

        self.combo_profiles = ttk.Combobox(prof_frame, state='readonly')
        self.combo_profiles.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
        self.combo_profiles.bind("<<ComboboxSelected>>", self.on_profile_change)

        ttk.Button(prof_frame, text="Загрузить профиль", command=self.load_profile).grid(row=0, column=1, padx=5)
        ttk.Button(prof_frame, text="Сохранить профиль", command=self.save_profile).grid(row=0, column=2, padx=5)
        ttk.Button(prof_frame, text="Обновить список", command=self.load_profiles).grid(row=0, column=3, padx=5)

        ttk.Label(tab, text="Выбор шаблона:").grid(row=1, column=0, sticky='w', padx=10, pady=(0, 5))
        self.combo_templates = ttk.Combobox(tab, state='readonly')
        self.combo_templates.grid(row=2, column=0, sticky="ew", padx=10)
        self.combo_templates.bind("<<ComboboxSelected>>", self.on_template_change)
        ttk.Button(tab, text="Предпросмотр шаблона", command=self.preview_template).grid(row=3, column=0, sticky='w', padx=10, pady=5)

        ttk.Label(tab, text="Плейсхолдеры:").grid(row=4, column=0, sticky='w', padx=10, pady=(5, 3))

        ph_container = ttk.Frame(tab)
        ph_container.grid(row=5, column=0, sticky="nsew", padx=10)
        ph_container.grid_columnconfigure(0, weight=1)
        ph_container.grid_rowconfigure(1, weight=1)

        search_frame = ttk.Frame(ph_container)
        search_frame.grid(row=0, column=0, sticky="ew")
        search_frame.columnconfigure(1, weight=1)

        ttk.Label(search_frame, text="Поиск:").grid(row=0, column=0, sticky="w")
        ent_ph_search = ttk.Entry(search_frame, textvariable=self.var_placeholder_search)
        ent_ph_search.grid(row=0, column=1, sticky="ew")
        ent_ph_search.bind("<KeyRelease>", self.on_placeholder_search)

        tree_frame = ttk.Frame(ph_container)
        tree_frame.grid(row=1, column=0, sticky="nsew", pady=(5, 0))
        tree_frame.grid_rowconfigure(0, weight=1)
        tree_frame.grid_columnconfigure(0, weight=1)

        self.tree_placeholders = ttk.Treeview(tree_frame, columns=("name", "value"), show='headings')
        self.tree_placeholders.grid(row=0, column=0, sticky="nsew")
        self.tree_placeholders.heading("name", text="Имя")
        self.tree_placeholders.heading("value", text="Значение")
        self.tree_placeholders.column("name", width=200, anchor="w")
        self.tree_placeholders.column("value", width=400, anchor="w")

        scrollbar_ph = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree_placeholders.yview)
        scrollbar_ph.grid(row=0, column=1, sticky="ns")
        self.tree_placeholders.configure(yscrollcommand=scrollbar_ph.set)

        ph_btn_frame = ttk.Frame(tab)
        ph_btn_frame.grid(row=7, column=0, sticky="ew", padx=10, pady=5)
        for i, (text, cmd) in enumerate([
            ("Добавить", self.add_placeholder),
            ("Редактировать", self.edit_placeholder),
            ("Удалить", self.delete_placeholder),
            ("Импорт из JSON", self.import_placeholders),
            ("Экспорт в JSON", self.export_placeholders),
        ]):
            ttk.Button(ph_btn_frame, text=text, command=cmd).grid(row=0, column=i, padx=5)

        style_label = ttk.Label(tab, text="Параметры таблицы:")
        style_label.grid(row=8, column=0, sticky='w', padx=10, pady=(10, 3))
        style_frm = ttk.Frame(tab)
        style_frm.grid(row=9, column=0, sticky="ew", padx=10, pady=5)
        style_frm.columnconfigure(10, weight=1)

        self.var_header_bold.set(self.settings.data['table_style'].get('header_bold', True))
        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(self.settings.data['table_style'].get('alignment', 'center'))
        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, self.settings.data['table_style'].get('font_name', 'Calibri'))
        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, self.settings.data['table_style'].get('font_size', 11))
        self.spin_font_size.grid(row=0, column=6, padx=5, pady=2)

        ttk.Label(style_frm, text="Стиль рамки:").grid(row=0, column=7, padx=5, pady=2, sticky='w')
        self.combo_border_style = ttk.Combobox(style_frm, values=['нет', 'тонкая', 'толстая'], state='readonly', width=10)
        self.combo_border_style.set(self.settings.data['table_style'].get('border_style', 'тонкая'))
        self.combo_border_style.grid(row=0, column=8, padx=5, pady=2)

        ttk.Label(style_frm, text="Шрифт заголовка раздела:").grid(row=1, column=0, sticky='w', padx=5, pady=3)
        self.entry_sec_header_font_name = ttk.Entry(style_frm, width=15)
        self.entry_sec_header_font_name.grid(row=1, column=1, padx=5)
        self.entry_sec_header_font_name.insert(0, self.settings.data['table_style'].get("section_header_font_name", "Calibri"))

        ttk.Label(style_frm, text="Размер шрифта:").grid(row=1, column=2, sticky='w', padx=5, pady=3)
        self.spin_sec_header_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_sec_header_font_size.grid(row=1, column=3, padx=5)
        self.spin_sec_header_font_size.delete(0, "end")
        self.spin_sec_header_font_size.insert(0, self.settings.data['table_style'].get("section_header_font_size", 12))

        self.var_sec_header_bold.set(self.settings.data['table_style'].get("section_header_bold", True))
        ttk.Checkbutton(style_frm, text="Жирный заголовок раздела", variable=self.var_sec_header_bold).grid(row=1, column=4, padx=5)

        ttk.Label(style_frm, text="Выравнивание заголовка:").grid(row=1, column=5, sticky='w', padx=5, pady=3)
        self.combo_sec_header_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_sec_header_align.grid(row=1, column=6, padx=5)
        self.combo_sec_header_align.set(self.settings.data['table_style'].get("section_header_align", "center"))

        ttk.Label(style_frm, text="Цвет фона заголовка (HEX):").grid(row=1, column=7, sticky='w', padx=5, pady=3)
        self.entry_sec_header_bg_color = ttk.Entry(style_frm, width=12)
        self.entry_sec_header_bg_color.grid(row=1, column=8, padx=5)
        self.entry_sec_header_bg_color.insert(0, self.settings.data['table_style'].get("section_header_bg_color", "FFFF00"))

        export_label = ttk.Label(tab, text="Настройки экспорта:")
        export_label.grid(row=10, column=0, sticky='w', padx=10, pady=(10, 3))
        export_frm = ttk.Frame(tab)
        export_frm.grid(row=11, column=0, sticky="ew", padx=10, pady=5)
        export_frm.grid_columnconfigure(8, weight=1)

        ttk.Label(export_frm, text="Формат файла:").grid(row=0, column=0, sticky="w", padx=5)
        self.combo_export_format = ttk.Combobox(export_frm, values=['docx', 'pdf'], state='readonly', width=10)
        self.combo_export_format.set(self.settings.data['export'].get('format', 'docx'))
        self.combo_export_format.grid(row=0, column=1, sticky="w", padx=5)

        self.var_compress_images.set(self.settings.data['export'].get('compress_images', True))
        ttk.Checkbutton(export_frm, text="Сжимать изображения", variable=self.var_compress_images).grid(row=0, column=2, sticky="w", padx=10)

        ttk.Label(export_frm, text="Пароль для PDF:").grid(row=0, column=3, sticky="w", padx=5)
        self.entry_pdf_password = ttk.Entry(export_frm, width=20, show='*')
        self.entry_pdf_password.insert(0, self.settings.data['export'].get('pdf_password', ''))
        self.entry_pdf_password.grid(row=0, column=4, sticky="w", padx=5)

        ttk.Button(tab, text="Сохранить настройки в профиль", command=self.save_profile).grid(row=12, column=0, sticky="e", padx=10, pady=10)

    def show_about(self):
        messagebox.showinfo("О программе", "Автоматизация техдокументации\nВерсия 1.0")


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


if __name__ == '__main__':
    main()
ASSISTANT
Обе версии кода реализуют похожий функционал — графический интерфейс для загрузки Excel, управления настройками, генерации документов Word (и при необходимости PDF) на основе шаблонов, а также работу с плейсхолдерами и корзиной товаров. При этом вторая версия кода содержит значительные улучшения по структурированности, чистоте, надежности и удобству использования. Ниже — сравнение основных различий и почему вторая версия лучше:

---

### 1. Логирование

- **Первая версия:**  
  Логгер жёстко привязан к глобальной переменной `app_ref` и UI (вставка текста в текстовый виджет лога).  
- **Вторая версия:**  
  Логгер инкапсулирован и принимает опциональный callback для логирования в UI. Это делает класс Logger более универсальным, облегчая тестирование и повторное использование.

---

### 2. Работа с настройками и плейсхолдерами

- **Первая версия:**  
  Загрузка и сохранение настроек содержат повторяющийся код по добавлению значений по умолчанию, что затрудняет поддержку. Класс Settings использует прямую работу с глобальными переменными и логгером.  
- **Вторая версия:**  
  Метод `_apply_defaults()` централизованно добавляет недостающие значения по умолчанию, что уменьшает дублирование. Также `Settings` и `Placeholders` принимают ссылку на логгер для вывода сообщений, что улучшает модульность.

---

### 3. Обработка данных (Excel, фильтры)

- **Вторая версия** улучшенно:

  - Функции для извлечения параметров разделены с использованием вспомогательной функции `split_by_idis` — код стал более читаемым и понятным.
  
  - Фильтрация параметров использует генераторы с `any()`, что проще и эффективнее, чем вложенные цикла с прерываниями.

---

### 4. UI (Tkinter)

- **Вторая версия:**

  - Инициализация переменных Tkinter `tk.StringVar` и др. происходит централизованно в конструкторе, что лучше по стилю.

  - Создание UI разбито на несколько функций (`create_tab_data`, `create_basket_frame`, `create_tab_settings_gen`), что улучшает читаемость и поддержку.

  - Добавлена возможность (запас для) потокового выполнения долгих задач (`load_excel_dialog` и `generate_document`) с использованием `threading.Thread`, что позволяет избежать «замораживания» UI при выполнении долгих операций.

  - Контекстные меню вызываются через `menu.tk_popup` и `menu.grab_release()`, что корректно при работе с Tkinter.

- **Первая версия:**  
  Весь UI создаётся в одном большом конструкторе, без потоков, что может привести к "зависанию" интерфейса при длительных операциях.

---

### 5. Генерация документов и PDF

- **Вторая версия:**  
  Генерация документов вынесена в отдельный поток (`threading.Thread`), что позволяет UI оставаться отзывчивым.  

  Вызовы ошибок и сообщений сделаны через `self.after(0, ...)` — корректный способ обновления UI из другого потока.

  Обработка создания PDF обернута в `try-except` с логированием и сообщениями об ошибках.  

- **Первая версия:**  
  Генерация происходит в основном потоке, что потенциально блокирует UI при работе с большими данными. Механизм ошибок и логирования выполнен проще.

---

### 6. Прочее

- Вторая версия содержит немного более осмысленные имена функций, разделение на малые логические части. Например, логику работы с плейсхолдерами и моделями вынесена в отдельные классы с зависимостью от логгера.

- Использование `setdefault` в нескольких местах облегчает добавление дефолтных значений.

- Вторая версия лучше подготовлена для будущего масштабирования с меньшим риском регресса.

---

## Вывод: **Вторая версия кода лучше**

Потому что она:

- Более модульна и удобочитаема.

- Использует логику с применением потоков для предотвращения блокировки UI.

- Корректно обращается с UI-элементами из потоков.

- Логгер и классы настроек независимы и совместимы с разными UI, что облегчает поддержку.

- UI разбит на логические части.

- Улучшена обработка фильтров и структуры данных.

Если цель — поддерживаемый, масштабируемый код с хорошим UX (отзывчивым UI), следует выбирать вторую версию и дальше её дорабатывать (например, реализовать лог в UI и горячие клавиши).

---

Если хотите, могу помочь в доработке конкретных частей из второй версии (например, вывод лога в интерфейсе или улучшение потоковой обработки).

turns-00079.parquet:36598

5abaab8e72ab066e989c77c1
turn 1/1gpt-4.1-mini-2025-04-14MaoriUnited States8 words
degenerate_repetitionAbsentFinal dense release
USER
hi 
ASSISTANT
Hello! How can I assist you today?