код приложение:
from dialogs import *
from synonyms import *
from translation import *
from utils import *
from fileio import *
from constants import *
import tkinter as tk
from tkinter import Menu, Frame, LabelFrame, Label, Entry, Button, StringVar, LEFT, X, BOTH, SUNKEN, W, ttk, filedialog, simpledialog, messagebox, Scrollbar, RIGHT, BOTTOM, Toplevel, Canvas, END, CENTER
import json
import pyperclip
import pandas as pd
import os
from bs4 import BeautifulSoup
import re
from functools import partial
import openpyxl
import collections
import traceback
import numpy as np

print('STARTED')

class AttributeEditor:
    MAPPINGS_FILE = 'mappings.json'
    EXCLUSIONS_FILE = 'exclusions.json'
    PROTECTED_VALUES_FILE = 'protected_values.json'
    SETTINGS_FILE = 'settings.json'
    CASE_NORM_FILE = 'case_normalization.json'
    REPLACEMENT_RULES_FILE = 'replacement_rules.json'
    PARAM_MERGE_SETTINGS_FILE = 'param_merge_settings.json'
    VALUE_MERGE_SETTINGS_FILE = 'value_merge_settings.json'

    def __init__(self, root):
        self.root = root
        self.root.title("Редактор атрибутов товаров")
        self.root.geometry("1200x700")
        try:
            self.root.state('zoomed')  # Разворачиваем окно на весь экран (Windows)
        except Exception:
            pass
        self.original_df = None
        self.excel_columns = []  # <--- добавлено для хранения исходных заголовков Excel
        self.param_mapping = {}
        self.original_values = {}
        self.manual_split_values = {}
        self.exclusions = []
        self.mappings = {}
        self.sort_directions = {}
        self.edit_entry = None
        self.edit_undo_stack = []
        self.df_columns = []
        self.mapping_sort_reverse = False
        self._mapping_items_cache = []
        self.settings = {
            "features_col": "",
            "extra_cols": [],
            "manual_split_values": {},
            "source_file_path": "",
            "output_folder_path": ""
        }
        self.case_normalization = {}
        self.manual_split_results = {}
        self.replacement_rules = []
        self.protected_values = []
        self.synonyms_manager = ValueSynonymsManager()
        self.synonyms_manager.add_callback(self.dynamic_update_main_table)
        self.value_translation_rules_manager = ValueTranslationRulesManager()
        self.exclusions_manager = ExclusionsManager()
        self.value_translation_rules_manager.add_callback(self.dynamic_update_main_table)
        self.exclusions_manager.add_callback(self.dynamic_update_main_table)
        self.value_translation_rules = []
        self.param_merge_rules = []
        self.typo_rules = []
        # --- Загружаем правила объединения параметров из файла ---
        self.load_param_merge_rules()
        # --- Загружаем правила перевода значений из файла, если нет в профиле ---
        if not self.value_translation_rules:
            try:
                with open('value_translation_rules.json', encoding='utf-8') as f:
                    self.value_translation_rules = json.load(f)
            except Exception:
                self.value_translation_rules = []
        self.translation_available = TRANSLATION_AVAILABLE
        if self.translation_available:
            self.translation_system = BuiltinTranslationSystem()
        else:
            self.translation_system = None
        self.column_history = {}  # Старая история (значения)
        self.column_op_history = {}
        self.article_to_source_row = {}
        # --- Путь к текущему профилю ---
        self.current_profile_path = None
        # --- Создаем папку для профилей ---
        self.profiles_dir = 'profiles'
        if not os.path.exists(self.profiles_dir):
            os.makedirs(self.profiles_dir)
        self.load_settings()
        self.setup_ui()
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)
        self.load_column_history()
        self.auto_load_last_profile()
        # --- Автоматическая загрузка переводов параметров при запуске ---
        self.load_param_translations_from_file()

    def setup_ui(self):
        self.create_compact_menu()
        main_frame = Frame(self.root)
        main_frame.pack(fill=BOTH, expand=True, padx=5, pady=2)
        files_frame = LabelFrame(main_frame, text="Файлы и настройки", font=("Arial", 9, "bold"))
        files_frame.pack(fill=X, pady=(0, 2))
        file_row1 = Frame(files_frame)
        file_row1.pack(fill=X, padx=5, pady=2)
        # --- Файл ---
        Label(file_row1, text="Файл:").pack(side=LEFT)
        self.source_file_var = StringVar(value=self.settings.get("source_file_path", ""))
        source_entry = Entry(file_row1, textvariable=self.source_file_var)
        source_entry.pack(side=LEFT, fill=X, expand=True, padx=2)
        source_entry.bind('<FocusOut>', lambda e: self.save_settings())
        Button(file_row1, text="...", width=3, command=self.browse_source_file).pack(side=LEFT, padx=2)
        # --- Столбец ---
        Label(file_row1, text="Столбец:").pack(side=LEFT, padx=(15, 2))
        self.features_col = Entry(file_row1)
        self.features_col.pack(side=LEFT, padx=2)
        self.features_col.insert(0, self.settings.get("features_col", ""))
        # --- Доп.столбцы ---
        Label(file_row1, text="Доп.столбцы:").pack(side=LEFT, padx=(15, 2))
        self.extra_cols_label = Label(file_row1, text="", anchor=W, relief=SUNKEN, bg="white")
        self.extra_cols_label.pack(side=LEFT, fill=X, expand=True, padx=2)
        Button(file_row1, text="...", width=3, command=self.show_extra_cols_dialog).pack(side=LEFT, padx=2)
        file_row2 = Frame(files_frame)
        file_row2.pack(fill=X, padx=5, pady=(0, 2))
        Label(file_row2, text="Папка:", width=6).pack(side=LEFT)
        self.output_folder_var = StringVar(value=self.settings.get("output_folder_path", ""))
        output_entry = Entry(file_row2, textvariable=self.output_folder_var, width=35)
        output_entry.pack(side=LEFT, fill=X, expand=True, padx=2)
        output_entry.bind('<FocusOut>', lambda e: self.save_settings())
        Button(file_row2, text="...", width=3, command=self.browse_output_folder).pack(side=LEFT, padx=2)
        toolbar_frame = LabelFrame(main_frame, text="Действия", font=("Arial", 9, "bold"))
        toolbar_frame.pack(fill=X, pady=(0, 2))
        toolbar = Frame(toolbar_frame)
        toolbar.pack(fill=X, padx=5, pady=3)
        actions_frame = Frame(toolbar)
        actions_frame.pack(side=LEFT)
        self.btn_load = Button(actions_frame, text="Загрузить", command=self.load_file, width=12)
        self.btn_load.pack(side=LEFT, padx=2)
        # Кнопки экспорта рядом с загрузкой
        self.btn_save_xlsx = Button(actions_frame, text="Сохранить в XLSX", command=self.save_results, width=16)
        self.btn_save_xlsx.pack(side=LEFT, padx=2)
        self.btn_save_csv = Button(actions_frame, text="Сохранить в CSV", command=self.export_to_csv, width=16)
        self.btn_save_csv.pack(side=LEFT, padx=2)
        self.notebook = ttk.Notebook(main_frame)
        self.notebook.pack(fill=BOTH, expand=True, pady=2)
        self.main_tab = Frame(self.notebook)
        self.notebook.add(self.main_tab, text="Основная таблица")
        self.setup_main_tab()
        self.values_translation_tab = Frame(self.notebook)
        self.notebook.add(self.values_translation_tab, text="Переводы значений")
        self.setup_values_translation_tab()
        self.parameters_tab = Frame(self.notebook)
        self.notebook.add(self.parameters_tab, text="📝 Параметры")
        self.setup_parameters_tab()
        # Вкладка для проверки и исправления опечаток
        self.typos_tab = Frame(self.notebook)
        self.notebook.add(self.typos_tab, text="Опечатки")
        self.setup_typos_tab()
        self.info_label = Label(main_frame, text="Готов к работе", relief=SUNKEN, anchor=W, font=("Arial", 8), bg="#F0F0F0")
        self.info_label.pack(fill=X, pady=(2, 0))
        # Добавляю панель профилей с выпадающим списком
        profile_frame = Frame(self.root)
        profile_frame.pack(fill=X, padx=10, pady=2)
        Label(profile_frame, text="Профиль:").pack(side=LEFT, padx=(0, 5))
        self.profile_var = StringVar()
        self.profile_combo = ttk.Combobox(profile_frame, textvariable=self.profile_var, state='readonly', width=40)
        self.profile_combo.pack(side=LEFT, padx=(0, 5))
        self.profile_combo.bind('<<ComboboxSelected>>', self.on_profile_selected)
        Button(profile_frame, text="Сохранить профиль", command=self.save_profile).pack(side=LEFT, padx=2)
        Button(profile_frame, text="Загрузить профиль", command=self.load_profile).pack(side=LEFT, padx=2)
        Button(profile_frame, text="Обновить список", command=self.update_profiles_list).pack(side=LEFT, padx=2)
        # Инициализируем список профилей
        self.update_profiles_list()

    def create_compact_menu(self):
        menubar = Menu(self.root)
        self.root.config(menu=menubar)
        file_menu = Menu(menubar, tearoff=0)
        menubar.add_cascade(label="Файл", menu=file_menu)
        file_menu.add_command(label="Загрузить файл", command=self.load_file)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.on_close)
        self.root.bind('<Control-z>', self.undo_action)
        self.root.bind('<Control-Z>', self.undo_action)
        self.root.bind('<Control-y>', self.redo_action)
        self.root.bind('<Control-Y>', self.redo_action)
        self.root.bind('<Control-c>', self.copy_selected_cell_event)
        self.root.bind('<Control-C>', self.copy_selected_cell_event)
        self.root.bind('<Control-v>', self.paste_to_selected_cell)
        self.root.bind('<Control-V>', self.paste_to_selected_cell)
        self.root.bind('<Control-a>', self.select_all_cells)
        self.root.bind('<Control-A>', self.select_all_cells)
        self.root.bind('<Control-f>', self.focus_search_entry)
        self.root.bind('<Control-F>', self.focus_search_entry)

    def setup_main_tab(self):
        if hasattr(self, 'main_tab'):
            for widget in self.main_tab.winfo_children():
                widget.destroy()
            top_frame = Frame(self.main_tab)
            top_frame.pack(fill=X, padx=5, pady=5)
            # Поиск по артикулу
            Label(top_frame, text="Поиск по артикулу:").pack(side=LEFT)
            self.search_var = StringVar()
            search_entry = Entry(top_frame, textvariable=self.search_var, width=20)
            search_entry.pack(side=LEFT, padx=(0,2))
            search_entry.bind('<KeyRelease>', lambda e: self.apply_article_search())
            Button(top_frame, text="Сброс", command=self.reset_article_search, width=7).pack(side=LEFT, padx=(0,8))
            # Поиск по параметру
            Label(top_frame, text="Поиск по параметру:").pack(side=LEFT)
            self.param_search_var = StringVar()
            param_search_entry = Entry(top_frame, textvariable=self.param_search_var, width=20)
            param_search_entry.pack(side=LEFT, padx=(0,2))
            param_search_entry.bind('<KeyRelease>', lambda e: self.apply_param_search())
            Button(top_frame, text="Сброс", command=self.reset_param_search, width=7).pack(side=LEFT, padx=(0,8))
            # Выбор столбца и замена
            Label(top_frame, text="Столбец:").pack(side=LEFT, padx=(0,2))
            self.replace_col_var = StringVar()
            self.replace_col_menu = ttk.Combobox(top_frame, textvariable=self.replace_col_var, state='readonly', width=36)
            self.replace_col_menu.pack(side=LEFT, padx=(0,5))
            Button(top_frame, text="Заменить…", command=self.open_replace_column_dialog, width=10).pack(side=LEFT, padx=(0,2))
            # Кнопка истории столбца
            Button(top_frame, text="История столбца", command=self.open_column_history_dialog, width=16).pack(side=LEFT, padx=(0,8))
            # Кнопка настройки столбцов
            Button(top_frame, text="Настроить столбцы", command=self.open_column_visibility_dialog, width=16).pack(side=LEFT, padx=(0,8))
            # Таблица с горизонтальным и вертикальным скроллами
            table_frame = Frame(self.main_tab)
            table_frame.pack(fill=BOTH, expand=True, padx=5, pady=2)
            yscroll = Scrollbar(table_frame, orient='vertical')
            xscroll = Scrollbar(table_frame, orient='horizontal')
            self.tree = ttk.Treeview(table_frame, show='headings', yscrollcommand=yscroll.set, xscrollcommand=xscroll.set)
            yscroll.config(command=self.tree.yview)
            xscroll.config(command=self.tree.xview)
            yscroll.pack(side=RIGHT, fill=Y)
            xscroll.pack(side=BOTTOM, fill=X)
            self.tree.pack(side=LEFT, fill=BOTH, expand=True)
            self.xscroll = xscroll  # Сохраняем ссылку на горизонтальный скроллбар
            self.tree.bind('<Control-C>', self.copy_selected_cell_event)
            self.tree.bind('<Double-1>', self.on_main_tree_double_click)
            self.tree.bind('<Button-3>', self.show_cell_menu)  # ПКМ
            self.tree.bind('<Control-c>', self.copy_cell_under_mouse_event)
            self.tree.bind('<Control-C>', self.copy_cell_under_mouse_event)
            self.cell_menu = tk.Menu(self.tree, tearoff=0)
            self.cell_menu.add_command(label="Копировать значение ячейки", command=self.copy_selected_cell)

    def setup_values_translation_tab(self):
        if hasattr(self, 'values_translation_tab'):
            for widget in self.values_translation_tab.winfo_children():
                widget.destroy()
            # --- КОМПАКТНЫЕ КНОПКИ НАВЕРХУ ---
            top_btns = Frame(self.values_translation_tab)
            top_btns.pack(fill=X, pady=5)
            Button(top_btns, text="Добавить", command=self.add_value_translation_rule_dialog, width=10).pack(side=LEFT, padx=2)
            Button(top_btns, text="Активировать все", command=self.activate_all_value_translations, width=14).pack(side=LEFT, padx=2)
            Button(top_btns, text="Деактивировать все", command=self.deactivate_all_value_translations, width=16).pack(side=LEFT, padx=2)
            # --- Canvas + Scrollbar для вертикального скролла ---
            canvas = Canvas(self.values_translation_tab)
            canvas.pack(side=LEFT, fill=BOTH, expand=True)
            vscroll = Scrollbar(self.values_translation_tab, orient='vertical', command=canvas.yview)
            vscroll.pack(side=RIGHT, fill=Y)
            canvas.configure(yscrollcommand=vscroll.set)
            inner_frame = Frame(canvas)
            canvas.create_window((0, 0), window=inner_frame, anchor='nw')
            def on_configure(event):
                canvas.configure(scrollregion=canvas.bbox('all'))
            inner_frame.bind('<Configure>', on_configure)
            self.value_translation_vars = []
            cb_frame = Frame(inner_frame)
            cb_frame.pack(fill=X, pady=5)
            Label(cb_frame, text="Для редактирования или удаления используйте кнопки рядом с правилом.", fg="blue").pack(anchor='w', pady=(0,5))
            for i, rule in enumerate(self.value_translation_rules):
                row = Frame(cb_frame)
                row.pack(fill=X, pady=1)
                var = tk.BooleanVar(value=rule.get('active', True))
                cb = tk.Checkbutton(row, text=f"{rule['original']} → {rule['translation']} ({rule.get('description','')})", variable=var,
                                   command=partial(self.toggle_value_translation_rule, i, var))
                cb.pack(side=LEFT)
                def make_edit(idx):
                    return lambda: self.edit_value_translation_rule_by_index(idx)
                btn_edit = Button(row, text="✎", command=make_edit(i), width=2)
                btn_edit.pack(side=LEFT, padx=2)
                def make_delete(idx):
                    return lambda: self.delete_value_translation_rule_by_index(idx)
                btn_del = Button(row, text="✖", command=make_delete(i), width=2, fg='red')
                btn_del.pack(side=LEFT, padx=2)
                self.value_translation_vars.append(var)

    def delete_value_translation_rule_by_index(self, idx):
        if 0 <= idx < len(self.value_translation_rules):
            del self.value_translation_rules[idx]
            self.auto_save_profile()
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()

    def add_value_translation_rule_dialog(self):
        from tkinter import simpledialog
        orig = simpledialog.askstring("Новое правило", "Исходное значение (единица):")
        if not orig:
            return
        trans = simpledialog.askstring("Новое правило", "На что заменить:")
        if trans is None:
            return
        desc = simpledialog.askstring("Новое правило", "Описание (необязательно):")
        self.add_value_translation_rule(orig, trans, desc)

    def edit_value_translation_rule_by_index(self, idx):
        from tkinter import simpledialog, messagebox
        if idx < 0 or idx >= len(self.value_translation_rules):
            messagebox.showinfo("Редактировать правило", "Некорректный индекс правила.")
            return
        rule = self.value_translation_rules[idx]
        orig = simpledialog.askstring("Редактировать правило", "Исходное значение (единица):", initialvalue=rule['original'])
        if not orig:
            return
        trans = simpledialog.askstring("Редактировать правило", "На что заменить:", initialvalue=rule['translation'])
        if trans is None:
            return
        desc = simpledialog.askstring("Редактировать правило", "Описание (необязательно):", initialvalue=rule.get('description',''))
        self.value_translation_rules[idx] = {'original': orig, 'translation': trans, 'description': desc or '', 'active': rule.get('active', True)}
        self.auto_save_profile()
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def delete_value_translation_rule(self):
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is not None:
            self.delete_value_translation_rule_by_index(idx)

    def toggle_value_translation_rule(self, idx, var):
        if 0 <= idx < len(self.value_translation_rules):
            self.value_translation_rules[idx]['active'] = var.get()
            self.auto_save_profile()
        self.fill_main_table_from_characteristics()

    def activate_all_value_translations(self):
        for rule in self.value_translation_rules:
            rule['active'] = True
        self.auto_save_profile()
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def deactivate_all_value_translations(self):
        for rule in self.value_translation_rules:
            rule['active'] = False
        self.auto_save_profile()
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def load_settings(self):
        self.settings = load_settings(self.SETTINGS_FILE)

    def load_param_merge_rules(self):
        # Загружает правила объединения параметров из файла
        import os, json
        rules_file = 'param_merge_rules.json'
        if os.path.exists(rules_file):
            try:
                with open(rules_file, 'r', encoding='utf-8') as f:
                    self.param_merge_rules = json.load(f)
            except Exception:
                self.param_merge_rules = []
        else:
            self.param_merge_rules = []

    def save_settings(self):
        save_settings(self.settings, self.SETTINGS_FILE)

    def auto_save_profile(self):
        # Автоматическое сохранение в текущий профиль без диалогов
        if not self.current_profile_path:
            return
        
        profile = {
            'param_translations': self.param_translations,
            'column_history': getattr(self, 'column_history', {}),
            'column_op_history': getattr(self, 'column_op_history', {}),
            'column_history_active': getattr(self, 'column_history_active', {}),
            'visible_columns': getattr(self, 'visible_columns', []),
            'settings': getattr(self, 'settings', {}),
            'last_file': getattr(self, 'last_file', ''),
            'value_translation_rules': getattr(self, 'value_translation_rules', []),
            'param_merge_rules': getattr(self, 'param_merge_rules', []),
            'typo_rules': getattr(self, 'typo_rules', []),
        }
        
        try:
            with open(self.current_profile_path, 'w', encoding='utf-8') as f:
                json.dump(profile, f, ensure_ascii=False, indent=2)
            with open('last_profile.txt', 'w', encoding='utf-8') as f:
                f.write(self.current_profile_path)
            # Обновляем список профилей в выпадающем списке
            self.update_profiles_list()
        except Exception as e:
            print(f'Ошибка автосохранения профиля: {e}')

    def load_file(self):
        self.save_column_history()
        file_path = self.source_file_var.get().strip()
        if not file_path:
            self.info_label.config(text='Файл не выбран')
            return
        if not os.path.exists(file_path):
            self.info_label.config(text='Ошибка: файл не существует')
            return
        try:
            if file_path.lower().endswith('.xlsx'):
                self.original_df = pd.read_excel(file_path)
            else:
                self.original_df = pd.read_csv(file_path, encoding='utf-8-sig')
            if self.original_df is None or self.original_df.empty:
                self.info_label.config(text='Файл пуст или не удалось загрузить')
                return
            self._pristine_df = self.original_df.copy(deep=True)
            # --- Сохраняем только нужные заголовки Excel ---
            expected_cols = [
                'Артикул',
                'Изображение',
                'Наименование',
                'Наименование с артикулом',
                'Описание',
                'Характеристики'
            ]
            self.excel_columns = [col for col in expected_cols if col in self.original_df.columns]
            self.df_columns = list(self.original_df.columns)
            self.info_label.config(text=f'Загружено: {file_path}')
            self.load_column_history()
            self.fill_main_table_from_characteristics()
            if hasattr(self, 'current_df') and self.current_df is not None:
                for rule in self.param_merge_rules:
                    params = rule.get('params') or rule.get('parameters')
                    target = rule.get('target')
                    if params and target:
                        self.perform_param_merge(params, target, apply_only=True)
        except Exception as e:
            self.info_label.config(text=f'Ошибка загрузки файла: {e}')
            return

    def parse_html_table_wide(self, html):
        soup = BeautifulSoup(html, 'html.parser')
        trs = soup.find_all('tr')
        if not trs:
            return [], []
        # Получаем артикулы из первой строки
        first_row = trs[0]
        ths = first_row.find_all(['td', 'th'])
        articles = [td.get_text(strip=True) for td in ths[1:]]  # Все ячейки после первой
        if not articles:
            # Если нет ячеек после первой, проверяем другие строки
            for tr2 in trs[1:]:
                tds2 = tr2.find_all(['td', 'th'])
                if len(tds2) > 1:
                    articles = [td.get_text(strip=True) for td in tds2[1:]]
                    break
        n_articles = len(articles)
        # Собираем параметры и значения
        params = []
        values_by_article = {art: {} for art in articles}
        for tr in trs[1:]:
            tds = tr.find_all(['td', 'th'])
            # Пропускаем заголовки разделов (colspan >= 3 или пустой первый столбец)
            if len(tds) == 1 and (tds[0].has_attr('colspan') and int(tds[0]['colspan']) >= 3):
                continue
            if len(tds) > 0 and (tds[0].get_text(strip=True) == '' or (tds[0].has_attr('colspan') and int(tds[0]['colspan']) >= 3)):
                continue
            param = tds[0].get_text(strip=True) if tds else ''
            if not param:
                continue
            if param not in params:
                params.append(param)
            # Значения для артикулов
            # Корректное определение td_idx для строк с/без параметра
            if tds and tds[0].get_text(strip=True):
                td_idx = 1
            else:
                td_idx = 0
            values = []
            art_idx = 0
            while art_idx < n_articles and td_idx < len(tds):
                td = tds[td_idx]
                colspan = int(td.get('colspan', 1))
                value = td.get_text(strip=True)
                for _ in range(colspan):
                    if art_idx < n_articles:
                        values.append(value)
                        art_idx += 1
                td_idx += 1
            while len(values) < n_articles:
                values.append('')
            for art, val in zip(articles, values):
                values_by_article[art][param] = val
        return articles, params, values_by_article

    def fill_main_table_from_characteristics(self):
        self.save_column_history()
        try:
            if not hasattr(self, 'tree') or self.original_df is None:
                return
            if not hasattr(self, 'column_mapping'):
                self.column_mapping = {}
            self.tree.delete(*self.tree.get_children())
            char_col = None
            for col in self.original_df.columns:
                if 'характеристик' in str(col).lower():
                    char_col = col
                    break
            if char_col is None:
                self.info_label.config(text="Столбец 'Характеристики' не найден")
                return
            extra_col_names = self.settings.get("extra_cols", [])
            available_extra_cols = [col for col in extra_col_names if col in self.original_df.columns]
            data = []
            all_params = set()
            # Вместо added_articles по артикулу модели, используем added_source_articles по исходному артикулу
            # added_source_articles = set()  # УДАЛЕНО
            for idx, row in self.original_df.iterrows():
                html = str(row[char_col])
                soup = BeautifulSoup(html, 'html.parser')
                trs = soup.find_all('tr')
                if not trs:
                    # Если нет таблицы, но есть артикул — добавляем строку с пустыми параметрами
                    if 'Артикул' in row and row['Артикул']:
                        row_dict = {
                            'Артикул исходный': row['Артикул'],
                            'Артикул модели': row['Артикул']
                        }
                        for col in available_extra_cols:
                            row_dict[col] = row[col] if col in row else ''
                        data.append(row_dict)
                    continue
                first_row = trs[0]
                ths = first_row.find_all(['td', 'th'])
                # Удаляем все невидимые символы из текста
                def clean_text(text):
                    return text.strip()
                # Универсальный парсер первой строки таблицы
                import re
                first_row_html = str(trs[0])
                td_matches = re.findall(r'<td.*?>(.*?)</td>', first_row_html, re.DOTALL)
                def html2text(text):
                    return re.sub(r'<.*?>', '', text).replace('\xa0', ' ').replace('&nbsp;', ' ').strip()
                # 1. Пробуем первую строку
                model_articles = [html2text(td) for td in td_matches[1:] if html2text(td)]
                # 2. Если ничего не нашли — ищем по всем строкам таблицы
                if not model_articles:
                    for tr in trs:
                        tds = re.findall(r'<td.*?>(.*?)</td>', str(tr), re.DOTALL)
                        if len(tds) > 1:
                            candidate = html2text(tds[1])
                            if candidate and candidate != row.get('Артикул', ''):
                                model_articles.append(candidate)
                # --- DEBUG: сохраняем td_matches и model_articles для каждой строки ---
                with open('model_articles_debug.txt', 'a', encoding='utf-8') as f:
                    f.write(f'Артикул: {row.get("Артикул")}\n')
                    f.write(f'td_matches: {td_matches}\n')
                    f.write(f'model_articles: {model_articles}\n')
                    f.write('-'*40 + '\n')
                if not model_articles:
                    model_articles = [row['Артикул']] if 'Артикул' in row else []
                if not model_articles:
                    continue
                values_by_article = {art: {} for art in model_articles}
                params_set = set()
                trs_list = list(trs[1:])
                i = 0
                while i < len(trs_list):
                    tr = trs_list[i]
                    tds = tr.find_all(['td', 'th'])
                    if (
                        (len(tds) == 1 and int(tds[0].get('colspan', 1)) >= 2)
                        or (len(tds) == 2 and int(tds[0].get('colspan', 1)) >= 2 and not tds[1].get_text(strip=True))
                        or (len(tds) == 2 and not tds[0].get_text(strip=True) and not tds[1].get_text(strip=True))
                    ):
                        i += 1
                        continue
                    if len(tds) == 1 and int(tds[0].get('colspan', 1)) == len(model_articles)+1:
                        i += 1
                        continue
                    if len(tds) == 2:
                        param = tds[0].get_text(strip=True)
                        val = tds[1].get_text("\n", strip=True)
                        val_lines = [v.strip() for v in val.split("\n") if v.strip()]
                        val = ', '.join(val_lines)
                        colspan = int(tds[1].get('colspan', 1))
                        if colspan >= len(model_articles):
                            for art in model_articles:
                                values_by_article[art][param] = val
                            params_set.add(param)
                        elif colspan > 1:
                            for art_idx in range(colspan):
                                if art_idx < len(model_articles):
                                    art = model_articles[art_idx]
                                    values_by_article[art][param] = val
                            params_set.add(param)
                        else:
                            if model_articles:
                                values_by_article[model_articles[0]][param] = val
                            params_set.add(param)
                        i += 1
                        continue
                    if len(tds) > 2:
                        param = tds[0].get_text(strip=True)
                        art_idx = 0
                        for val_td in tds[1:]:
                            val_lines = self.parse_td_lines(val_td)
                            val = ', '.join(val_lines)
                            colspan = int(val_td.get('colspan', 1))
                            for k in range(colspan):
                                if art_idx < len(model_articles):
                                    art = model_articles[art_idx]
                                    values_by_article[art][param] = val
                                    art_idx += 1
                        params_set.add(param)
                        i += 1
                        continue
                    i += 1
                all_params.update(params_set)
                # Фильтрация уникальных model_articles
                unique_model_articles = []
                for art in model_articles:
                    if art not in unique_model_articles:
                        unique_model_articles.append(art)
                # Для каждого артикула модели формируем только одну строку, значения параметров по индексу
                for idx_art, art in enumerate(unique_model_articles):
                    row_dict = {
                        'Артикул исходный': row['Артикул'] if 'Артикул' in row else '',
                        'Артикул модели': art
                    }
                    for param in all_params:
                        # Попробовать взять значение по индексу артикула модели из trs, если структура позволяет
                        val = ''
                        for tr in trs[1:]:
                            tds = tr.find_all(['td', 'th'])
                            if len(tds) > idx_art + 1:
                                p = tds[0].get_text(strip=True)
                                if p == param:
                                    val = tds[idx_art + 1].get_text(strip=True)
                                    break
                        if not val:
                            val = values_by_article[art].get(param, '')
                        row_dict[param] = val
                    for col in available_extra_cols:
                        row_dict[col] = row[col] if col in row else ''
                    # Проверка на уникальность пары (исходный артикул, артикул модели)
                    already = any(
                        d['Артикул исходный'] == row['Артикул'] and d['Артикул модели'] == art
                        for d in data
                    )
                    if already:
                        continue
                    data.append(row_dict)
            # --- ГАРАНТИЯ: добавить все артикулы, которых нет в data ---
            # (БЛОК ПОЛНОСТЬЮ УДАЛЕН)
            def norm_art(art):
                return str(art).strip().replace('\xa0', '').replace('\u00a0', '').replace(' ', '').upper()
            for idx, row in self.original_df.iterrows():
                if 'Артикул' in row and row['Артикул']:
                    already = any(norm_art(d.get('Артикул исходный')) == norm_art(row['Артикул']) for d in data)
                    if not already:
                        row_dict = {
                            'Артикул исходный': row['Артикул'],
                            'Артикул модели': row['Артикул']
                        }
                        for col in available_extra_cols:
                            row_dict[col] = row[col] if col in row else ''
                        data.append(row_dict)
            # --- Сохраняем промежуточный результат data для отладки ---
            import pandas as pd
            pd.DataFrame(data).to_csv('data_debug.csv', index=False, encoding='utf-8-sig')
            # Удаляем дубли по паре (Артикул исходный, Артикул модели)
            seen_pairs = set()
            unique_data = []
            for row_dict in data:
                pair = (row_dict['Артикул исходный'], row_dict['Артикул модели'])
                if pair not in seen_pairs:
                    unique_data.append(row_dict)
                    seen_pairs.add(pair)
            data = unique_data
            # Формируем итоговый DataFrame
            translations = getattr(self, 'param_translations', {})
            def get_translation(param):
                norm = normalize_param_name(param)
                return translations.get(norm) or translations.get(param) or param
            sorted_params = sorted(list(all_params), key=lambda p: get_translation(p).lower())
            # ГАРАНТИЯ: добавить все выбранные доп. столбцы в columns
            extra_cols_all = set(available_extra_cols) | set(self.settings.get('extra_cols', []))
            columns = ['Артикул исходный', 'Артикул модели'] + sorted_params + list(extra_cols_all)
            df = pd.DataFrame(data, columns=columns)
            df = df.fillna('')
            # Гарантируем, что все выбранные доп. столбцы есть в df (на всякий случай)
            for col in self.settings.get('extra_cols', []):
                if col not in df.columns:
                    df[col] = ''
            self.last_table_columns = columns
            # self.df_columns = list(self.original_df.columns)  # УДАЛЯЮ эту строку, чтобы не перезаписывать df_columns
            self.current_df = df
            self.apply_value_translation_rules()
            # self.merge_duplicate_columns_by_normalized_name()  # ОТКЛЮЧЕНО: не объединять строки по нормализованному имени
            self.refresh_main_table_from_df()
            self.setup_parameters_tab()
            self.init_param_translations_from_tree()
            self.refresh_main_table_headers_with_translations()
            if hasattr(self, 'setup_typos_tab'):
                self.setup_typos_tab()
            _pristine_table_df = self.current_df.copy(deep=True)
            for col, ops in self.column_op_history.items():
                self.column_history_active[col] = [True] * len(ops)
            for col in self.column_op_history:
                found = False
                if hasattr(self, '_pristine_table_df') and col in self._pristine_table_df.columns:
                    found = True
                elif hasattr(self, '_pristine_df') and col in self._pristine_df.columns:
                    found = True
                elif hasattr(self, 'original_df') and col in self.original_df.columns:
                    found = True
                if found:
                    self.recalc_column_from_history(col)
            if hasattr(self, 'visible_columns'):
                filtered = [col for col in self.visible_columns if col in self.current_df.columns]
                if filtered:
                    self.visible_columns = filtered
                else:
                    self.visible_columns = list(self.current_df.columns)
            else:
                self.visible_columns = list(self.current_df.columns)
                self.refresh_main_table_from_df()
                self.refresh_main_table_headers_with_translations()
            self.column_mapping = {self.normalize_colname_for_treeview(col): col for col in columns}
            self.refresh_main_table_headers_with_translations()
        except Exception as e:
            traceback.print_exc()
            self.info_label.config(text=f'Ошибка обработки данных: {str(e)}')

    def merge_duplicate_columns_by_normalized_name(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        norm_to_cols = {}
        for col in df.columns:
            norm = normalize_param_name(col)
            norm_to_cols.setdefault(norm, []).append(col)
        new_cols = {}
        for norm, cols in norm_to_cols.items():
            if len(cols) > 1:
                from synonyms import clean_value_enhanced
                import pandas as pd  # <-- добавляем импорт здесь для замыкания
                def filter_and_join(row):
                    vals = []
                    for v in row:
                        if pd.isna(v):
                            continue
                        cleaned = clean_value_enhanced(str(v))
                        if cleaned:
                            vals.append(cleaned)
                    return '; '.join(vals) if vals else ''
                merged = df[cols].astype(str).apply(filter_and_join, axis=1)
                new_cols[norm] = merged
        if new_cols:
            import pandas as pd
            new_cols_df = pd.DataFrame(new_cols, index=df.index)
            df = pd.concat([df, new_cols_df], axis=1)
            # Удаляем дублирующиеся столбцы, кроме нормализованного
            for norm, cols in norm_to_cols.items():
                if len(cols) > 1:
                    for c in cols:
                        if c != norm and c in df.columns:
                            df.drop(columns=[c], inplace=True)
            df = df.copy()  # дефрагментация
        # --- Удаляем nan и строку 'nan' ---
        import numpy as np
        df = df.replace({np.nan: '', 'nan': ''})
        self.current_df = df
        self.refresh_main_table_from_df()
        self.refresh_main_table_headers_with_translations()

    def save_results(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            self.info_label.config(text="Нет данных для сохранения")
            return
        extra_col_names = self.settings.get("extra_cols", [])
        available_extra_cols = [col for col in extra_col_names if col in self.original_df.columns]
        # --- Собираем итоговый DataFrame для выгрузки ---
        df_out = self.current_df.copy()
        df_out = df_out.fillna('')
        # --- Используем переводы параметров для выгрузки ---
        translations = getattr(self, 'param_translations', None)
        # --- Перемещаем доп. столбцы в конец ---
        all_cols = list(df_out.columns)
        extra_cols_at_end = [col for col in available_extra_cols if col in all_cols]
        main_cols = [col for col in all_cols if col not in extra_cols_at_end]
        ordered_cols = main_cols + extra_cols_at_end
        df_out = df_out[ordered_cols]
        if translations:
            df_out.rename(columns={col: translations.get(normalize_param_name(col), col) for col in df_out.columns}, inplace=True)
        out_path = os.path.join(os.path.dirname(self.settings.get('source_file_path', '')), 'woocommerce_ready.xlsx')
        try:
            df_out.to_excel(out_path, index=False)
            self.info_label.config(text=f"Файл сохранён: {out_path}")
        except Exception as e:
            self.info_label.config(text=f"Ошибка при сохранении: {e}")

    def open_translation_dialog(self):
        BuiltinTranslationDialog(self.root, self.translation_system)

    def open_replacement_rules_editor(self):
        ReplacementRulesEditorDialog(self.root, self.replacement_rules, self.save_settings)

    def open_case_normalization_dialog(self):
        CaseNormalizationDialog(self.root, self.case_normalization, self.save_settings)

    def open_value_synonyms_dialog(self):
        ValueSynonymsDialog(self.root, self.synonyms_manager)

    def open_protected_values_dialog(self):
        if 'ProtectedValuesDialog' in globals():
            ProtectedValuesDialog(self.root)
        else:
            from tkinter import messagebox
            messagebox.showinfo("Защищённые значения", "Здесь будет диалог для защищённых значений.")

    def open_exclusions_dialog(self):
        ExclusionsDialog(self.root, self.exclusions_manager)

    def show_extra_cols_dialog(self):
        # Используем только заголовки из исходного Excel-файла (self.excel_columns)
        if not (hasattr(self, 'excel_columns') and self.excel_columns):
            messagebox.showwarning("Ошибка", "Сначала загрузите файл")
            return
        print('DEBUG: excel_columns:', self.excel_columns)  # Для отладки
        current_selection = set(self.settings.get('extra_cols', []))
        dialog = Toplevel(self.root)
        dialog.title("Выбор дополнительных столбцов")
        dialog.geometry("400x500")
        vars = {}
        frame = Frame(dialog)
        frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        cols = list(self.excel_columns)
        for col in cols:
            var = tk.BooleanVar(value=col in current_selection)
            chk = Checkbutton(frame, text=f"{cols.index(col)+1}. {col}", variable=var)
            chk.pack(anchor=W)
            vars[col] = var
        def apply():
            selected = [col for col, v in vars.items() if v.get()]
            self.settings['extra_cols'] = selected
            self.update_extra_cols_label()
            self.save_settings()
            self.fill_main_table_from_characteristics()
            dialog.destroy()
        Button(dialog, text="Применить", command=apply).pack(pady=10)

    def browse_source_file(self):
        filename = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")])
        if filename:
            self.source_file_var.set(filename)
            self.save_settings()

    def browse_output_folder(self):
        folder = filedialog.askdirectory(title="Выберите папку для сохранения")
        if folder:
            self.output_folder_var.set(folder)
            self.save_settings()

    def refresh_mapping_table(self):
        if hasattr(self, 'tree') and hasattr(self, 'mappings'):
            self.tree.delete(*self.tree.get_children())
            for i, (orig, repl) in enumerate(self.mappings.items(), 1):
                self.tree.insert('', 'end', values=(i, orig, '', repl, '', ''))

    def dynamic_update_main_table(self):
        self.refresh_mapping_table()
        self.info_label.config(text="Таблица обновлена (callback)")

    def on_close(self):
        self.save_settings()
        # --- Автосохранение переводов параметров ---
        if hasattr(self, 'save_param_translations') and hasattr(self, 'param_translations') and hasattr(self, 'current_df'):
            try:
                self.save_param_translations()
            except Exception as e:
                print('Ошибка автосохранения переводов:', e)
        
        # --- Предлагаем сохранить изменения при закрытии ---
        if self.current_profile_path:
            # Если есть текущий профиль, спрашиваем о сохранении
            result = messagebox.askyesnocancel(
                "Сохранить изменения", 
                f"Сохранить все изменения в профиль?\n{self.current_profile_path}",
                icon='question'
            )
            if result is True:  # Да
                self.auto_save_profile()
                messagebox.showinfo("Сохранено", f"Изменения сохранены в профиль:\n{self.current_profile_path}")
            elif result is None:  # Отмена
                return  # Не закрываем приложение
        else:
            # Если нет текущего профиля, предлагаем создать новый
            result = messagebox.askyesno(
                "Сохранить изменения", 
                "Сохранить все изменения в новый профиль?",
                icon='question'
            )
            if result:
                self.save_profile()
        
        self.root.destroy()

    def copy_selected_value(self):
        selected = self.tree.selection()
        if selected:
            value = self.tree.item(selected[0])['values']
            pyperclip.copy(str(value))
            self.info_label.config(text="Значение скопировано в буфер обмена")
        else:
            self.info_label.config(text="Нет выбранной строки для копирования")

    def show_cell_menu(self, event):
        region = self.tree.identify('region', event.x, event.y)
        if region == 'cell':
            rowid = self.tree.identify_row(event.y)
            col = self.tree.identify_column(event.x)
            self.tree.selection_set(rowid)
            self.tree.focus(rowid)
            self._cell_menu_row = rowid
            self._cell_menu_col = col
            self.cell_menu.post(event.x_root, event.y_root)

    def copy_selected_cell(self):
        try:
            rowid = getattr(self, '_cell_menu_row', None)
            col = getattr(self, '_cell_menu_col', None)
            if rowid and col:
                col_idx = int(col.replace('#', '')) - 1
                values = self.tree.item(rowid)['values']
                if 0 <= col_idx < len(values):
                    pyperclip.copy(str(values[col_idx]))
        except Exception:
            pass

    def copy_cell_under_mouse_event(self, event=None):
        # Копировать значение ячейки под мышью или выделенной
        try:
            x = self.tree.winfo_pointerx() - self.tree.winfo_rootx()
            y = self.tree.winfo_pointery() - self.tree.winfo_rooty()
            rowid = self.tree.identify_row(y)
            col = self.tree.identify_column(x)
            if rowid and col:
                # Получаем список отображаемых (видимых) столбцов
                displaycolumns = self.tree['displaycolumns']
                if displaycolumns == '#all':
                    displaycolumns = self.tree['columns']
                col_idx = int(col.replace('#', '')) - 1
                if 0 <= col_idx < len(displaycolumns):
                    norm_col = displaycolumns[col_idx]
                    all_columns = self.tree['columns']
                    value_idx = list(all_columns).index(norm_col)
                values = self.tree.item(rowid)['values']
                if 0 <= value_idx < len(values):
                    pyperclip.copy(str(values[value_idx]))
                    self.info_label.config(text="Значение ячейки скопировано")
                    return
            # fallback: если не удалось, копируем первую ячейку выделенной строки
            selected = self.tree.selection()
            if selected:
                values = self.tree.item(selected[0])['values']
                if values:
                    pyperclip.copy(str(values[0]))
                    self.info_label.config(text="Значение ячейки скопировано")
        except Exception:
            pass

    def copy_selected_cell_event(self, event=None):
        try:
            selected = self.tree.selection()
            if not selected:
                return
            rowid = selected[0]
            focus_col = self.tree.focus()
            # Получаем выделенную ячейку (по фокусу или первой колонке)
            col = self.tree.identify_column(event.x) if event and hasattr(event, 'x') else '#1'
            col_idx = int(col.replace('#', '')) - 1
            values = self.tree.item(rowid)['values']
            if 0 <= col_idx < len(values):
                pyperclip.copy(str(values[col_idx]))
        except Exception:
            pass

    def open_replace_column_dialog(self):
        col = self.replace_col_var.get()
        if not col or not hasattr(self, 'current_df') or self.current_df is None:
            return
        dialog = Toplevel(self.root)
        dialog.title(f"Замена в столбце: {col}")
        dialog.geometry("400x200")
        Label(dialog, text=f"Столбец: {col}").pack(pady=5)
        find_var = StringVar()
        replace_var = StringVar()
        regex_var = IntVar()
        frame = Frame(dialog)
        frame.pack(pady=10)
        Label(frame, text="Что заменить:").grid(row=0, column=0, sticky=W)
        Entry(frame, textvariable=find_var, width=30).grid(row=0, column=1)
        Label(frame, text="На что заменить:").grid(row=1, column=0, sticky=W)
        Entry(frame, textvariable=replace_var, width=30).grid(row=1, column=1)
        Checkbutton(dialog, text="Использовать регулярное выражение", variable=regex_var).pack()
        Button(dialog, text="Заменить", command=lambda: self.apply_column_replace(dialog, col, find_var.get(), replace_var.get(), regex_var.get())).pack(pady=10)

    def apply_column_replace(self, dialog, col, find, replace, use_regex):
        import re
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df.copy()
        orig_col = self.get_original_column_name(col)
        def norm(s): return ''.join(s.lower().split())
        real_col = None
        if orig_col:
            for c in df.columns:
                if c == orig_col:
                    real_col = c
                    break
            if not real_col:
                for c in df.columns:
                    if c.lower() == orig_col.lower():
                        real_col = c
                        break
            if not real_col:
                ncol = norm(orig_col)
                for c in df.columns:
                    if norm(c) == ncol:
                        real_col = c
                        break
        if not real_col:
            for c in df.columns:
                if c == col:
                    real_col = c
                    break
        if not real_col:
            for c in df.columns:
                if c.lower() == col.lower():
                    real_col = c
                    break
        if not real_col:
            ncol = norm(col)
            for c in df.columns:
                if norm(c) == ncol:
                    real_col = c
                    break
        print(f"DEBUG: apply_column_replace real_col={real_col}, col={col}, orig_col={orig_col}")
        if not real_col or real_col not in df.columns:
            from tkinter import messagebox
            real_cols = list(df.columns)
            msg = f'Столбец "{col}" не найден в таблице!\n\nТекущие столбцы:\n' + '\n'.join(real_cols)
            print('DEBUG: Все столбцы DataFrame:', real_cols)
            messagebox.showerror('Ошибка', msg)
            dialog.destroy()
            return
        # Применяем замену только по точному совпадению (без глобального re.sub)
        if use_regex:
            df[real_col] = df[real_col].astype(str).apply(lambda x: re.sub(find, replace, x, flags=re.IGNORECASE))
        else:
            df[real_col] = df[real_col].astype(str).apply(lambda x: x.replace(find, replace))
        # --- Сохраняем историю изменений ---
        if real_col not in self.column_op_history:
            self.column_op_history[real_col] = []
        op_descr = f"Замена: '{find}' → '{replace}' (regex: {bool(use_regex)})"
        print(f"DEBUG: apply_column_replace op_descr={op_descr}")
        self.column_op_history[real_col].append(op_descr)
        if real_col not in self.column_history:
            self.column_history[real_col] = []
        self.column_history[real_col].append(list(df[real_col]))
        if not hasattr(self, 'column_history_active'):
            self.column_history_active = {}
        if real_col not in self.column_history_active:
            self.column_history_active[real_col] = []
        self.column_history_active[real_col].append(True)
        self.save_column_history()
        self.auto_save_profile()
        self.current_df = df
        self.refresh_main_table_from_df()
        self.save_undo_state()
        # --- АВТОСОХРАНЕНИЕ DataFrame после замены ---
        if hasattr(self, 'source_file_var') and self.source_file_var.get():
            file_path = self.source_file_var.get()
            try:
                if file_path.lower().endswith('.xlsx'):
                    self.current_df.to_excel(file_path, index=False)
                else:
                    self.current_df.to_csv(file_path, index=False, encoding='utf-8-sig')
                self.info_label.config(text=f'Изменения сохранены в {file_path}')
            except Exception as e:
                print('Ошибка автосохранения:', e)
        dialog.destroy()

    def get_original_column_name(self, selected_col):
        df = self.current_df
        translations = getattr(self, 'param_translations', None)
        # 1. Поиск по переводу → оригинал
        if translations:
            for orig, tr in translations.items():
                if tr == selected_col:
                    return orig
        # 2. Регистр-независимо
        if translations:
            for orig, tr in translations.items():
                if tr.lower() == selected_col.lower():
                    return orig
        # 3. Нормализованно
        def norm(s): return ''.join(s.lower().split())
        ncol = norm(selected_col)
        if translations:
            for orig, tr in translations.items():
                if norm(tr) == ncol:
                    return orig
        # 4. По всем столбцам DataFrame
        for c in df.columns:
            if c == selected_col or c.lower() == selected_col.lower() or norm(c) == ncol:
                return c
        return None

    def apply_article_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        search_text = self.search_var.get().strip().lower()
        if not search_text:
            self.reset_article_search()
            return
        df = self.current_df
        filtered = df[df['Артикул'].str.lower().str.contains(search_text, na=False)]
        self.tree.delete(*self.tree.get_children())
        columns = list(df.columns)
        for i, row in filtered.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)

    def reset_article_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        # Очищаем поле поиска
        self.search_var.set('')
        df = self.current_df
        self.tree.delete(*self.tree.get_children())
        columns = list(df.columns)
        for i, row in df.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)

    def apply_param_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        search_text = self.param_search_var.get().strip().lower()
        if not search_text:
            self.reset_param_search()
            return
        df = self.current_df
        translations = getattr(self, 'param_translations', {})
        columns = list(df.columns)
        service_cols = ['Артикул исходный', 'Артикул модели']
        extra_cols = self.settings.get('extra_cols', [])
        col_to_display = {col: translations.get(normalize_param_name(col), col) for col in columns}
        filtered_cols = [col for col, display in col_to_display.items() if search_text in display.lower()]
        display_cols = [col for col in service_cols if col in columns] + filtered_cols + [col for col in extra_cols if col in columns]
        if not filtered_cols:
            display_cols = [col for col in service_cols if col in columns] + [col for col in extra_cols if col in columns]
        # Преобразуем display_cols в нормализованные имена для Treeview
        norm_display_cols = []
        for col in display_cols:
            for norm_col, orig_col in self.column_mapping.items():
                if orig_col == col:
                    norm_display_cols.append(norm_col)
                    break
        self.tree['displaycolumns'] = norm_display_cols
        self.refresh_main_table_headers_with_translations()

    def reset_param_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        self.param_search_var.set('')
        # Используем нормализованные имена столбцов для Treeview
        self.tree['displaycolumns'] = list(self.tree['columns'])
        self.refresh_main_table_from_df()
        self.refresh_main_table_headers_with_translations()

    def open_column_visibility_dialog(self):
        dialog = Toplevel(self.root)
        dialog.title("Настройка видимости столбцов")
        dialog.geometry("400x500")
        columns = list(self.current_df.columns)
        translations = getattr(self, 'param_translations', {})
        extra_cols = set(self.settings.get('extra_cols', []))
        service_cols = ['Артикул исходный', 'Артикул модели']
        def get_translation(param):
            norm = normalize_param_name(param)
            return translations.get(norm) or translations.get(param) or param
        param_cols = [col for col in columns if col not in service_cols and col not in extra_cols]
        param_cols_sorted = sorted(param_cols, key=lambda p: get_translation(p).lower())
        columns_sorted = [col for col in service_cols if col in columns] + param_cols_sorted + [col for col in columns if col in extra_cols]
        # --- Используем только self.visible_columns для выставления флагов ---
        visible_columns = set(getattr(self, 'visible_columns', columns_sorted))
        vars = {}
        btns = Frame(dialog)
        btns.pack(fill=X, pady=10)
        def select_all():
            self._bulk_checkbox_update = True
            for v in vars.values():
                v.set(1)
            self._bulk_checkbox_update = False
            update_table_on_checkbox()
        def deselect_all():
            self._bulk_checkbox_update = True
            for v in vars.values():
                v.set(0)
            self._bulk_checkbox_update = False
            update_table_on_checkbox()
        def update_table_on_checkbox(*args):
            if getattr(self, '_bulk_checkbox_update', False):
                return
            # Список отмеченных чекбоксов (только из реально существующих в DataFrame)
            selected = [col for col in columns_sorted if col in vars and vars[col].get()]
            filtered_columns = [col for col in selected if col in self.current_df.columns]
            if not filtered_columns:
                filtered_columns = list(self.current_df.columns)
                for col in columns_sorted:
                    if col in vars:
                        vars[col].set(1)
            self.visible_columns = filtered_columns
            # Не фильтруем DataFrame! Просто обновляем displaycolumns
            self.refresh_main_table_from_df(self.current_df)
            norm_cols = []
            for col in filtered_columns:
                for norm_col in self.tree['columns']:
                    if self.column_mapping.get(norm_col) == col:
                        norm_cols.append(norm_col)
                        break
            self.tree['displaycolumns'] = norm_cols if norm_cols else list(self.tree['columns'])
            self.auto_save_profile()
        canvas = Canvas(dialog)
        canvas.pack(side=LEFT, fill=BOTH, expand=True)
        scrollbar = Scrollbar(dialog, orient=VERTICAL, command=canvas.yview)
        scrollbar.pack(side=RIGHT, fill=Y)
        canvas.configure(yscrollcommand=scrollbar.set)
        inner_frame = Frame(canvas)
        canvas.create_window((0, 0), window=inner_frame, anchor='nw')
        def on_configure(event):
            canvas.configure(scrollregion=canvas.bbox('all'))
        inner_frame.bind('<Configure>', on_configure)
        for i, col in enumerate(columns_sorted):
            var = IntVar(value=1 if col in visible_columns else 0)
            vars[col] = var
            display_name = get_translation(col)
            chk = Checkbutton(inner_frame, text=display_name, variable=var)
            chk.pack(anchor=W)
            var.trace_add('write', update_table_on_checkbox)
        # --- Кнопки управления ---
        btns = Frame(dialog)
        btns.pack(fill=X, pady=10)
        Button(btns, text="Выделить все", command=select_all).pack(side=LEFT, padx=5)
        Button(btns, text="Снять все", command=deselect_all).pack(side=LEFT, padx=5)

    def open_column_history_dialog(self, real_col=None):
        # Гарантируем, что всегда выбран валидный столбец
        if not hasattr(self, 'current_df') or self.current_df is None or self.current_df.columns.size == 0:
            from tkinter import messagebox
            messagebox.showinfo('История столбца', 'Нет доступных столбцов для отображения истории.')
            return
        current_display_col = self.replace_col_var.get() if hasattr(self, 'replace_col_var') else None
        # Если не передан real_col — берём выбранный в replace_col_var или первый доступный
        if real_col is None:
            if not current_display_col or current_display_col not in self.current_df.columns:
                self.replace_col_var.set(self.current_df.columns[0])
                current_display_col = self.current_df.columns[0]
            col = self.replace_col_var.get()
            if not col or col not in self.current_df.columns:
                from tkinter import messagebox
                messagebox.showinfo('История столбца', 'Столбец не выбран или отсутствует в таблице.')
                return
            real_col = col
        # Если для столбца нет истории — показываем окно с надписью 'История пуста'
        history = self.column_history.get(real_col, [])
        active_flags = self.column_history_active.get(real_col, [True]*len(history))
        dialog = Toplevel(self.root)
        dialog.title(f"История изменений: {real_col}")
        dialog.geometry("800x500")
        # --- Синхронизируем выпадающий список "Столбец:" ---
        if hasattr(self, 'replace_col_var'):
            columns_list = list(self.replace_col_menu['values']) if hasattr(self, 'replace_col_menu') else []
            if current_display_col and current_display_col in columns_list:
                self.replace_col_var.set(current_display_col)
            else:
                display_col = real_col
                if hasattr(self, 'param_translations'):
                    for orig, tr in self.param_translations.items():
                        if orig == real_col:
                            display_col = tr
                            break
                self.replace_col_var.set(display_col)
        Label(dialog, text=f"История изменений столбца: {real_col}").pack(pady=5)
        frame = Frame(dialog)
        frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        if not history:
            Label(frame, text="История пуста", fg="gray").pack(pady=20)
            return
        # --- Список операций с чекбоксом и крестиком на одной строке ---
        op_frame = Frame(frame)
        op_frame.pack(fill=BOTH, expand=True)
        for idx, op in enumerate(self.column_op_history.get(real_col, [])):
            row = Frame(op_frame)
            row.pack(fill=X, pady=2)
            is_active = self.column_history_active[real_col][idx] if idx < len(self.column_history_active[real_col]) else True
            var = tk.BooleanVar(value=is_active)
            def make_toggle(idx_toggle, var=var):
                return lambda *_: self.toggle_column_history_op(real_col, idx_toggle, var.get(), dialog, real_col)
            chk = Checkbutton(row, variable=var, command=make_toggle(idx, var))
            chk.pack(side=LEFT)
            Label(row, text=f"{idx+1}. {op}", anchor='w').pack(side=LEFT, fill=X, expand=True)
            # --- Кнопка редактирования ---
            def make_edit(idx_edit):
                return lambda: edit_rule(real_col, idx_edit, dialog)
            btn_edit = Button(row, text="✎", command=make_edit(idx), width=2)
            btn_edit.pack(side=RIGHT)
            # --- Кнопки ▲▼ для изменения порядка ---
            def make_move_up(idx_move):
                return lambda: move_rule(real_col, idx_move, -1, dialog)
            def make_move_down(idx_move):
                return lambda: move_rule(real_col, idx_move, 1, dialog)
            btn_up = Button(row, text="▲", command=make_move_up(idx), width=2)
            btn_up.pack(side=RIGHT)
            btn_down = Button(row, text="▼", command=make_move_down(idx), width=2)
            btn_down.pack(side=RIGHT)
            def make_delete(idx_del):
                return lambda: delete_op(idx_del)
            btn = Button(row, text="✖", command=make_delete(idx), width=2, fg='red')
            btn.pack(side=RIGHT)
        # --- Функция для редактирования правила ---
        def edit_rule(col, idx, dialog):
            import re
            op = self.column_op_history[col][idx]
            m = re.search(r"Замена: '(.+)' → '(.*)' \(regex: (True|False)\)", op)
            if not m:
                from tkinter import messagebox
                messagebox.showerror('Ошибка', 'Можно редактировать только правила замены!')
                return
            find, replace, regex = m.group(1), m.group(2), m.group(3) == "True"
            edit_dialog = Toplevel(self.root)
            edit_dialog.title("Редактировать правило замены")
            Label(edit_dialog, text="Что заменить:").pack()
            entry_find = Entry(edit_dialog)
            entry_find.pack(fill='x', padx=12)
            entry_find.insert(0, find)
            Label(edit_dialog, text="На что заменить:").pack()
            entry_replace = Entry(edit_dialog)
            entry_replace.pack(fill='x', padx=12)
            entry_replace.insert(0, replace)
            var_regex = tk.BooleanVar(value=regex)
            chk_regex = Checkbutton(edit_dialog, text="Regex", variable=var_regex)
            chk_regex.pack()
            def save_edit():
                new_find = entry_find.get()
                new_replace = entry_replace.get()
                new_regex = var_regex.get()
                new_op = f"Замена: '{new_find}' → '{new_replace}' (regex: {'True' if new_regex else 'False'})"
                self.column_op_history[col][idx] = new_op
                self.save_column_history()
                self.recalc_column_from_history(col)
                edit_dialog.destroy()
                dialog.destroy()
                self.open_column_history_dialog(col)
            Button(edit_dialog, text="Сохранить", command=save_edit).pack(pady=5)
            Button(edit_dialog, text="Отмена", command=edit_dialog.destroy).pack()
        def move_rule(col, idx, direction, dialog):
            ops = self.column_op_history[col]
            acts = self.column_history_active[col]
            hist = self.column_history.get(col, [])
            new_idx = idx + direction
            if 0 <= new_idx < len(ops):
                # Меняем местами правило, активность и снапшот
                ops[idx], ops[new_idx] = ops[new_idx], ops[idx]
                acts[idx], acts[new_idx] = acts[new_idx], acts[idx]
                if len(hist) == len(ops):
                    hist[idx], hist[new_idx] = hist[new_idx], hist[idx]
                self.column_op_history[col] = ops
                self.column_history_active[col] = acts
                self.column_history[col] = hist
                self.save_column_history()
                self.recalc_column_from_history(col)
                dialog.destroy()
                self.open_column_history_dialog(col)
        def delete_op(idx_del):
            if real_col in self.column_op_history and idx_del < len(self.column_op_history[real_col]):
                del self.column_op_history[real_col][idx_del]
            if real_col in self.column_history and idx_del < len(self.column_history[real_col]):
                del self.column_history[real_col][idx_del]
            if real_col in self.column_history_active and idx_del < len(self.column_history_active[real_col]):
                del self.column_history_active[real_col][idx_del]
            self.save_column_history()
            # Пересчёт столбца из оригинала с применением только активных операций
            self.recalc_column_from_history(real_col)
            dialog.destroy()
            self.open_column_history_dialog(real_col)
        def clear_all():
            self.column_op_history[real_col] = []
            self.column_history[real_col] = []
            self.column_history_active[real_col] = []
            self.save_column_history()
            if hasattr(self, '_pristine_table_df') and real_col in self._pristine_table_df.columns:
                self.current_df[real_col] = self._pristine_table_df[real_col]
            elif hasattr(self, '_pristine_df') and real_col in self._pristine_df.columns:
                self.current_df[real_col] = self._pristine_df[real_col]
            elif hasattr(self, 'original_df') and real_col in self.original_df.columns:
                self.current_df[real_col] = self.original_df[real_col]
            self.refresh_main_table_from_df()
            dialog.destroy()
        Button(dialog, text="Очистить все", command=clear_all, fg='red').pack(pady=10)

    # --- Реализация undo/redo и универсальных действий ---
    def undo_action(self, event=None):
        if not hasattr(self, 'undo_stack'):
            self.undo_stack = []
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        if self.undo_stack:
            last_state = self.undo_stack.pop()
            if hasattr(self, 'current_df'):
                self.redo_stack.append(self.current_df.copy())
                self.current_df = last_state
                self.refresh_main_table_from_df()
            # Для других вкладок
            if hasattr(self, 'active_tab') and self.active_tab == 'params_merge':
                if hasattr(self, 'params_merge_undo_stack') and self.params_merge_undo_stack:
                    state = self.params_merge_undo_stack.pop()
                    self.params_merge_redo_stack.append(self.param_merge_rules_manager.rules.copy())
                    self.param_merge_rules_manager.rules = state
                    self.setup_params_merge_tab()
            if hasattr(self, 'active_tab') and self.active_tab == 'values_translation':
                if hasattr(self, 'values_translation_undo_stack') and self.values_translation_undo_stack:
                    state = self.values_translation_undo_stack.pop()
                    self.values_translation_redo_stack.append(self.value_translation_rules.copy())
                    self.value_translation_rules = state
                    self.setup_values_translation_tab()

    def redo_action(self, event=None):
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        if self.redo_stack:
            state = self.redo_stack.pop()
            if hasattr(self, 'current_df'):
                self.save_undo_state()
                self.current_df = state
                self.refresh_main_table_from_df()
            # Для других вкладок
            if hasattr(self, 'active_tab') and self.active_tab == 'params_merge':
                if hasattr(self, 'params_merge_redo_stack') and self.params_merge_redo_stack:
                    state = self.params_merge_redo_stack.pop()
                    self.save_params_merge_undo_state()
                    self.param_merge_rules_manager.rules = state
                    self.setup_params_merge_tab()
            if hasattr(self, 'active_tab') and self.active_tab == 'values_translation':
                if hasattr(self, 'values_translation_redo_stack') and self.values_translation_redo_stack:
                    state = self.values_translation_redo_stack.pop()
                    self.save_values_translation_undo_state()
                    self.value_translation_rules = state
                    self.setup_values_translation_tab()

    def save_undo_state(self):
        if not hasattr(self, 'undo_stack'):
            self.undo_stack = []
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        import copy
        self.undo_stack.append(copy.deepcopy(self.current_df))
        self.redo_stack.clear()

    # Для других вкладок:
    def save_params_merge_undo_state(self):
        if not hasattr(self, 'params_merge_undo_stack'):
            self.params_merge_undo_stack = []
        if not hasattr(self, 'params_merge_redo_stack'):
            self.params_merge_redo_stack = []
        import copy
        self.params_merge_undo_stack.append(copy.deepcopy(self.param_merge_rules_manager.rules))
        self.params_merge_redo_stack.clear()

    def save_values_translation_undo_state(self):
        if not hasattr(self, 'values_translation_undo_stack'):
            self.values_translation_undo_stack = []
        if not hasattr(self, 'values_translation_redo_stack'):
            self.values_translation_redo_stack = []
        import copy
        self.values_translation_undo_stack.append(copy.deepcopy(self.value_translation_rules))
        self.values_translation_redo_stack.clear()

    def copy_selected_cell_event(self, event=None):
        # Копирование выделенных ячеек/строк в Excel-совместимом формате (TSV)
        import pyperclip
        selected = self.tree.selection()
        if not selected:
            return
        normalized_columns = self.tree['columns']
        data = []
        for item in selected:
            row = self.tree.item(item, 'values')
            data.append('\t'.join(str(row[i]) for i in range(len(normalized_columns))))
        pyperclip.copy('\n'.join(data))

    def paste_to_selected_cell(self, event=None):
        import pyperclip
        value = pyperclip.paste()
        rows = value.split('\n')
        selected = self.tree.selection()
        if not selected:
            return
        normalized_columns = self.tree['columns']
        column_mapping = getattr(self, 'column_mapping', {})
        for idx, item in enumerate(selected):
            if idx < len(rows):
                row_values = list(self.tree.item(item, 'values'))
                pasted = rows[idx].split('\t')
                for col_idx in range(min(len(pasted), len(row_values))):
                    # --- Сохраняем историю операций ---
                    norm_col = normalized_columns[col_idx]
                    orig_col = column_mapping.get(norm_col, norm_col)
                    if orig_col not in self.column_op_history:
                        self.column_op_history[orig_col] = []
                    old_val = row_values[col_idx]
                    new_val = pasted[col_idx]
                    op_descr = f"Вставка из буфера: строка {idx}, '{old_val}' → '{new_val}'"
                    self.column_op_history[orig_col].append(op_descr)
                    if orig_col not in self.column_history:
                        self.column_history[orig_col] = []
                    self.column_history[orig_col].append(list(self.current_df[orig_col]))
                    self.save_undo_state()
                    row_values[col_idx] = pasted[col_idx]
                self.tree.item(item, values=row_values)

    def select_all_cells(self, event=None):
        for item in self.tree.get_children():
            self.tree.selection_add(item)

    def focus_search_entry(self, event=None):
        # Фокус на поле поиска артикула
        if hasattr(self, 'search_var'):
            for widget in self.main_tab.winfo_children():
                if isinstance(widget, Frame):
                    for child in widget.winfo_children():
                        if isinstance(child, Entry):
                            child.focus_set()
                            return

    def refresh_main_table_from_df(self, df=None):
        # Обновляет основную таблицу из DataFrame
        if df is None:
            df = self.current_df
        if df is None:
            return
        # --- Корректируем visible_columns: только реально существующие столбцы ---
        if hasattr(self, 'visible_columns'):
            self.visible_columns = [col for col in self.visible_columns if col in df.columns]
        if not getattr(self, 'visible_columns', []):
            self.visible_columns = list(df.columns)
        # --- Защита: всегда используем только self.visible_columns, если они заданы ---
        # Теперь всегда отображаем все столбцы, а видимость управляем через displaycolumns
        columns = list(df.columns)
        if not columns:
            return  # Нет столбцов — не обновляем таблицу
        # --- Удаляем nan и строку 'nan' ---
        import numpy as np
        df = df.replace({np.nan: '', 'nan': ''})
        self.current_df = df
        self.tree.delete(*self.tree.get_children())
        normalized_columns = []
        column_mapping = {}
        for col in columns:
            norm_col = self.normalize_colname_for_treeview(col)
            counter = 1
            original_norm = norm_col
            while norm_col in column_mapping or norm_col in normalized_columns:
                norm_col = f"{original_norm}_{counter}"
                counter += 1
            if not norm_col or not norm_col[0].isalpha():
                norm_col = 'col_' + str(counter) if norm_col else 'col_1'
            column_mapping[norm_col] = col
            normalized_columns.append(norm_col)
        if not normalized_columns:
            return  # Нет валидных столбцов — не обновляем таблицу
        self.tree['columns'] = normalized_columns
        self.tree['show'] = 'headings'
        if hasattr(self, 'xscroll'):
            self.tree.configure(xscrollcommand=self.xscroll.set)
        for norm_col in normalized_columns:
            orig_col = column_mapping.get(norm_col, norm_col)
            if orig_col not in df.columns:
                continue
            try:
                self.tree.heading(norm_col, text=orig_col)
                max_len = max([len(str(x)) for x in [orig_col] + list(df[orig_col])] + [10])
                width = min(max_len * 10, 800)
                self.tree.column(norm_col, width=width, minwidth=80, stretch=False)
            except Exception:
                continue
        for i, row in df.iterrows():
            values = []
            for norm_col in normalized_columns:
                orig_col = column_mapping[norm_col]
                if orig_col in df.columns:
                    values.append(row[orig_col])
            else:
                values.append("")
            # --- DEBUG PRINT ---
            try:
                print('UI:', row['Артикул исходный'], row['Артикул модели'])
            except Exception:
                pass
            self.tree.insert('', 'end', values=values)
        self.column_mapping = column_mapping
        self.refresh_main_table_headers_with_translations()

    def on_main_tree_double_click(self, event):
        region = self.tree.identify('region', event.x, event.y)
        if region != 'cell':
            return
        rowid = self.tree.identify_row(event.y)
        col = self.tree.identify_column(event.x)
        if not rowid or not col:
            return
        col_idx = int(col.replace('#', '')) - 1
        normalized_columns = self.tree['columns']
        column_mapping = getattr(self, 'column_mapping', {})
        values = list(self.tree.item(rowid, 'values'))
        old_value = values[col_idx]
        entry = Entry(self.tree)
        entry.insert(0, old_value)
        entry.select_range(0, END)
        entry.focus_set()
        x, y, width, height = self.tree.bbox(rowid, col)
        entry.place(x=x, y=y, width=width, height=height)
        def save_and_destroy(event=None):
            new_value = entry.get().strip()
            if new_value != old_value:
                values[col_idx] = new_value
                self.tree.item(rowid, values=values)
                # Сохраняем в DataFrame
                idx = self.tree.index(rowid)
                norm_col = normalized_columns[col_idx]
                orig_col = column_mapping.get(norm_col, norm_col)
                self.current_df.at[self.current_df.index[idx], orig_col] = new_value
                # --- Сохраняем историю операций ---
                if orig_col not in self.column_op_history:
                    self.column_op_history[orig_col] = []
                op_descr = f"Ручное редактирование: строка {idx}, '{old_value}' → '{new_value}'"
                self.column_op_history[orig_col].append(op_descr)
                if orig_col not in self.column_history:
                    self.column_history[orig_col] = []
                self.column_history[orig_col].append(list(self.current_df[orig_col]))
                self.auto_save_profile()
            entry.destroy()
        entry.bind('<Return>', save_and_destroy)
        entry.bind('<FocusOut>', save_and_destroy)

    def update_extra_cols_label(self):
        # Обновляет отображение выбранных доп. столбцов с номерами
        extra_cols = self.settings.get('extra_cols', [])
        if hasattr(self, 'df_columns') and self.df_columns:
            display_text = ', '.join([
                f"{self.df_columns.index(col)+1}. {col}" if col in self.df_columns else col
                for col in extra_cols
            ]) if extra_cols else ''
        else:
            display_text = ', '.join(extra_cols) if extra_cols else ''
        self.extra_cols_label.config(text=display_text)

    def add_value_translation_rule(self, orig, trans, desc=None):
        self.value_translation_rules.append({'original': orig, 'translation': trans, 'description': desc or '', 'active': True})
        self.auto_save_profile()
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def edit_value_translation_rule(self):
        from tkinter import simpledialog, messagebox
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is None:
            messagebox.showinfo("Редактировать правило", "Сначала выберите правило для редактирования.")
            return
        self.edit_value_translation_rule_by_index(idx)

    def delete_value_translation_rule(self):
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is not None:
            self.delete_value_translation_rule_by_index(idx)

    def setup_parameters_tab(self):
        for widget in self.parameters_tab.winfo_children():
            widget.destroy()
        columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
        translations = getattr(self, 'param_translations', {})
        def get_translation(param):
            norm = normalize_param_name(param)
            return translations.get(norm) or translations.get(param) or param
        # Исключаем служебные и доп. столбцы
        service_cols = {'Артикул исходный', 'Артикул модели'} | set(self.settings.get('extra_cols', []))
        param_cols = [col for col in columns if col not in service_cols]
        # --- Сортируем параметры по переводу ---
        param_cols_sorted = sorted(param_cols, key=lambda p: get_translation(p).lower())
        columns_sorted = list(service_cols & set(columns)) + param_cols_sorted + [col for col in columns if col in self.settings.get('extra_cols', [])]
        old_translations = getattr(self, 'param_translations', {})
        btns_frame = Frame(self.parameters_tab)
        btns_frame.pack(fill=X, padx=10, pady=5)
        Button(btns_frame, text="Экспорт в JSON", command=self.export_params_json).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Импорт из JSON", command=self.import_params_json).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Экспорт в XLSX", command=self.export_params_xlsx).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Импорт из XLSX", command=self.import_params_xlsx).pack(side=LEFT, padx=2)
        Label(self.parameters_tab, text="Перевод в столбце 'Перевод параметра' формируется автоматически через BuiltinTranslationSystem (title case + словарь переводов), но вы можете вручную отредактировать перевод двойным кликом.", fg="blue").pack(fill=X, padx=10, pady=(0,5))
        tree_frame = Frame(self.parameters_tab)
        tree_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        self.param_tree = ttk.Treeview(tree_frame, columns=("num", "param", "translation", "orig"), show="headings", height=20, selectmode="extended")
        self.param_tree.heading("num", text="№")
        self.param_tree.heading("param", text="Исходные параметры")
        self.param_tree.heading("translation", text="Перевод параметра")
        self.param_tree.column("num", width=40, anchor=CENTER)
        self.param_tree.column("param", width=250, anchor=W)
        self.param_tree.column("translation", width=250, anchor=W)
        self.param_tree.column("orig", width=0, stretch=False, anchor=W, minwidth=0)
        self.param_tree.pack(side=LEFT, fill=BOTH, expand=True)
        vsb = Scrollbar(tree_frame, orient="vertical", command=self.param_tree.yview)
        self.param_tree.configure(yscrollcommand=vsb.set)
        vsb.pack(side=RIGHT, fill=Y)
        self.param_translation_vars = {}
        for idx, orig in enumerate(param_cols_sorted, 1):
            norm_orig = normalize_param_name(orig)
            translation = old_translations.get(norm_orig)
            if translation is None:
                translation = old_translations.get(orig)
            if translation is None:
                translation = self.translation_system.translate_text(orig, is_parameter=True) if self.translation_system else orig
            self.param_tree.insert("", "end", values=(idx, orig, translation, orig))
            self.param_translation_vars[orig] = translation
        self.param_tree.bind('<Double-1>', self.on_param_tree_double_click)
        self.param_menu = tk.Menu(self.param_tree, tearoff=0)
        self.param_menu.add_command(label="Объединить параметры и их значения", command=self.merge_selected_params)
        self.param_tree.bind('<Button-3>', self.show_param_context_menu)
        self.init_param_translations_from_tree()
        self.refresh_main_table_headers_with_translations()
        # --- Обновляем replace_col_menu после пересоздания параметров ---
        self.update_replace_col_menu()  # <--- ЗАМЕНА прямого присваивания

    def load_param_translations_from_file(self):
        import os, json
        file = 'param_translations.json'
        if os.path.exists(file):
            with open(file, 'r', encoding='utf-8') as f:
                translations = json.load(f)
            # Обновляем дерево параметров
            for item in self.param_tree.get_children():
                param = self.param_tree.set(item, "param")
                norm_param = normalize_param_name(param)
                if norm_param in translations:
                    self.param_tree.set(item, "translation", translations[norm_param])
            self.param_translations = translations
        else:
            self.param_translations = {normalize_param_name(self.param_tree.set(item, "param")): self.param_tree.set(item, "translation") for item in self.param_tree.get_children()}

    def init_param_translations_from_tree(self):
        # Инициализирует self.param_translations из текущего дерева параметров
        if hasattr(self, 'param_tree'):
            columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
            self.param_translations = {}
            for item in self.param_tree.get_children():
                orig = self.param_tree.set(item, "param")
                translation = self.param_tree.set(item, "translation")
                if orig in columns:
                    # Сохраняем с нормализованным ключом для совместимости
                    norm_orig = normalize_param_name(orig)
                    self.param_translations[norm_orig] = translation

    def on_param_tree_double_click(self, event):
        item = self.param_tree.identify_row(event.y)
        column = self.param_tree.identify_column(event.x)
        if not item or column != '#3':  # '#3' — это столбец 'Перевод параметра'
            return
        x, y, width, height = self.param_tree.bbox(item, column)
        value = self.param_tree.set(item, "translation")
        entry = Entry(self.param_tree)
        entry.insert(0, value)
        entry.select_range(0, END)
        entry.focus_set()
        entry.place(x=x, y=y, width=width, height=height)
        def save_edit(event=None):
            new_value = entry.get().strip()
            self.param_tree.set(item, "translation", new_value)
            orig = self.param_tree.set(item, "orig")
            self.param_translation_vars[orig] = new_value
            entry.destroy()
            self.save_param_translations()
            self.auto_save_profile()
            self.refresh_main_table_headers_with_translations()
            self.fill_main_table_from_characteristics()
            self.setup_parameters_tab()
        entry.bind('<Return>', save_edit)
        entry.bind('<FocusOut>', save_edit)

    def save_param_translations(self):
        import json
        # Сохраняем только переводы для реально существующих столбцов
        columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
        param_translations = {}
        for item in self.param_tree.get_children():
            orig = self.param_tree.set(item, "orig")
            translation = self.param_tree.set(item, "translation")
            norm_orig = normalize_param_name(orig)
            if norm_orig in [normalize_param_name(col) for col in columns]:
                param_translations[norm_orig] = translation
        self.param_translations = param_translations
        with open('param_translations.json', 'w', encoding='utf-8') as f:
            json.dump(self.param_translations, f, ensure_ascii=False, indent=2)
        if hasattr(self, 'info_label') and self.info_label:
            self.info_label.config(text="Переводы параметров сохранены")
        self.refresh_main_table_headers_with_translations()
        self.update_replace_col_menu()

    def refresh_main_table_headers_with_translations(self):
        # Обновляет заголовки столбцов основной таблицы на переводы
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        normalized_columns = list(self.tree['columns'])
        translations = getattr(self, 'param_translations', None)
        column_mapping = getattr(self, 'column_mapping', {})
        for norm_col in normalized_columns:
            orig_col = column_mapping.get(norm_col, norm_col)
            display = get_param_translation(orig_col, translations)
            if not display:
                if hasattr(self, 'translation_system') and self.translation_system:
                    display = self.translation_system.translate_text(orig_col, is_parameter=True)
                if not display:
                    display = orig_col
            try:
                self.tree.heading(norm_col, text=display)
                if orig_col in df.columns:
                    max_len = max([len(str(x)) for x in [display] + list(df[orig_col])] + [10])
                    width = min(max_len * 10, 800)
                    self.tree.column(norm_col, width=width, minwidth=80, stretch=False)
                else:
                    self.tree.column(norm_col, width=120, minwidth=80, stretch=False)
            except Exception:
                continue
        self.update_replace_col_menu()

    def edit_value_translation_rule_by_index(self, idx):
        import json
        from tkinter import messagebox
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        if idx < 0 or idx >= len(rules):
            messagebox.showinfo("Редактировать правило", "Некорректный индекс правила.")
            return
        rule = rules[idx]
        # --- Новый диалог ---
        dialog = Toplevel(self.root)
        dialog.title("Редактировать правило")
        dialog.geometry("350x180")
        dialog.grab_set()
        Label(dialog, text="Исходное значение (единица):").pack(pady=(12,2), anchor='w', padx=12)
        entry_orig = Entry(dialog)
        entry_orig.pack(fill='x', padx=12)
        entry_orig.insert(0, rule['original'])
        Label(dialog, text="Перевод:").pack(pady=(10,2), anchor='w', padx=12)
        entry_trans = Entry(dialog)
        entry_trans.pack(fill='x', padx=12)
        entry_trans.insert(0, rule['translation'])
        Label(dialog, text="Описание (необязательно):").pack(pady=(10,2), anchor='w', padx=12)
        entry_desc = Entry(dialog)
        entry_desc.pack(fill='x', padx=12)
        entry_desc.insert(0, rule.get('description',''))
        def on_ok():
            orig = entry_orig.get()
            trans = entry_trans.get()
            desc = entry_desc.get()
            if not orig or not trans:
                messagebox.showwarning("Ошибка", "Заполните оба поля: исходное значение и перевод.")
                return
            rules[idx] = {'original': orig, 'translation': trans, 'description': desc or '', 'active': rule.get('active', True)}
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            dialog.destroy()
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()
        def on_cancel():
            dialog.destroy()
        btns = Frame(dialog)
        btns.pack(pady=12)
        Button(btns, text="OK", width=10, command=on_ok).pack(side=LEFT, padx=8)
        Button(btns, text="Cancel", width=10, command=on_cancel).pack(side=LEFT, padx=8)
        entry_orig.focus_set()

    def show_param_context_menu(self, event):
        selection = self.param_tree.selection()
        if len(selection) >= 2:
            self.param_menu.tk_popup(event.x_root, event.y_root)

    def merge_selected_params(self):
        selection = self.param_tree.selection()
        if len(selection) < 2:
            return
        # Получаем пары (оригинал, перевод) для выбранных параметров
        param_pairs = [(self.param_tree.set(item, "param"), self.param_tree.set(item, "translation")) for item in selection]
        # Для радиокнопок: показываем перевод + оригинал, значение — оригинал
        radio_options = [(f"{trans} ({orig})", orig) for orig, trans in param_pairs]
        # Диалог выбора итогового названия
        top = Toplevel(self.root)
        top.title("Выберите итоговое название параметра")
        Label(top, text="Выберите перевод, который останется после объединения:").pack(padx=10, pady=10)
        var = tk.StringVar(value=radio_options[0][1])
        for label, value in radio_options:
            Radiobutton(top, text=label, variable=var, value=value).pack(anchor=W, padx=10)
        def do_merge():
            target_orig = var.get()
            try:
                self.perform_param_merge([orig for orig, _ in param_pairs], target_orig)
            finally:
                top.destroy()
                self.save_param_translations()  # автосохранение после объединения
                self.auto_save_profile()  # сохраняем в профиль
        Button(top, text="Объединить", command=do_merge).pack(pady=10)

    def perform_param_merge(self, params, target, apply_only=False):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df.copy()
        df = self.drop_duplicate_columns_by_normalized(df)
        columns = list(df.columns)
        # Обновляем column_mapping после слияния
        if hasattr(self, 'column_mapping'):
            # Создаём новый маппинг для обновлённого DataFrame
            new_mapping = {}
            for col in df.columns:
                norm_col = self.normalize_colname_for_treeview(col)
                counter = 1
                original_norm = norm_col
                while norm_col in new_mapping:
                    norm_col = f"{original_norm}_{counter}"
                    counter += 1
                if not norm_col or not norm_col[0].isalpha():
                    norm_col = 'col_' + str(counter) if norm_col else 'col_1'
                new_mapping[norm_col] = col
            self.column_mapping = new_mapping
        self.refresh_main_table_from_df(df)
        merged_values = []
        for p in params:
            if p in df.columns:
                merged_values.append(df[p].astype(str))
        if merged_values:
            import pandas as pd
            from synonyms import clean_value_enhanced
            def filter_and_join(*args):
                vals = []
                for v in args:
                    if pd.isna(v):
                        continue
                    cleaned = clean_value_enhanced(str(v))
                    if cleaned:
                        vals.append(cleaned)
                return '; '.join(vals) if vals else ''
            merged = pd.DataFrame(merged_values).T.apply(lambda row: filter_and_join(*row), axis=1)
            df[target] = merged
        # Удаляем все объединённые столбцы, кроме target
        for p in params:
            if p != target and p in df.columns:
                df.drop(columns=[p], inplace=True)
        self.current_df = df
        # --- Сброс видимых столбцов только по новым ---
        self.visible_columns = list(df.columns)
        self.setup_parameters_tab()
        self.refresh_main_table_from_df()
        # --- Сохраняем правило объединения только если это не применение существующего правила ---
        if not apply_only:
            merge_rule = {'params': params, 'target': target}
            # Не добавлять дубликаты
            if not any(set(r.get('params', [])) == set(params) and r.get('target') == target for r in self.param_merge_rules):
                self.param_merge_rules.append(merge_rule)
            # Сохраняем в профиль
            self.auto_save_profile()

    def export_params_json(self):
        import json
        params = [{
            'param': self.param_tree.set(item, 'param'),
            'translation': self.param_tree.set(item, 'translation')
        } for item in self.param_tree.get_children()]
        file = filedialog.asksaveasfilename(defaultextension='.json', filetypes=[('JSON files', '*.json')])
        if file:
            with open(file, 'w', encoding='utf-8') as f:
                json.dump(params, f, ensure_ascii=False, indent=2)
            self.info_label.config(text=f'Экспортировано в {file}')
            self.save_param_translations()  # автосохранение после экспорта
            self.auto_save_profile()  # сохраняем в профиль

    def import_params_json(self):
        import json
        file = filedialog.askopenfilename(filetypes=[('JSON files', '*.json')])
        if file:
            with open(file, 'r', encoding='utf-8') as f:
                params = json.load(f)
            param_map = {normalize_param_name(row['param']): row['translation'] for row in params}
            updated = 0
            for item in self.param_tree.get_children():
                orig = self.param_tree.set(item, 'param')
                norm_orig = normalize_param_name(orig)
                if norm_orig in param_map:
                    self.param_tree.set(item, 'translation', param_map[norm_orig])
                    updated += 1
            self.info_label.config(text=f'Импортировано из {file}. Обновлено переводов: {updated}')
            self.init_param_translations_from_tree()
            self.save_param_translations()
            self.auto_save_profile()
            self.fill_main_table_from_characteristics()
            # --- Применяем все правила объединения параметров ---
            if hasattr(self, 'param_merge_rules') and self.param_merge_rules:
                for rule in self.param_merge_rules:
                    params = rule.get('params') or rule.get('parameters')
                    target = rule.get('target')
                    if params and target:
                        self.perform_param_merge(params, target, apply_only=True)
            self.setup_parameters_tab()
            self.update_replace_col_menu()  # <--- ДОБАВЛЕНО

    def export_params_xlsx(self):
        import openpyxl
        from openpyxl import Workbook
        params = [{
            'param': self.param_tree.set(item, 'param'),
            'translation': self.param_tree.set(item, 'translation')
        } for item in self.param_tree.get_children()]
        file = filedialog.asksaveasfilename(defaultextension='.xlsx', filetypes=[('Excel files', '*.xlsx')])
        if file:
            wb = Workbook()
            ws = wb.active
            ws.append(['Исходные параметры', 'Перевод параметра'])
            for row in params:
                ws.append([row['param'], row['translation']])
            wb.save(file)
            self.info_label.config(text=f'Экспортировано в {file}')
            self.save_param_translations()  # автосохранение после экспорта
            self.auto_save_profile()  # сохраняем в профиль

    def import_params_xlsx(self):
        import openpyxl
        file = filedialog.askopenfilename(filetypes=[('Excel files', '*.xlsx')])
        if file:
            wb = openpyxl.load_workbook(file)
            ws = wb.active
            rows = list(ws.iter_rows(values_only=True))
            param_map = {normalize_param_name(row[0]): row[1] for row in rows[1:] if row and row[0]}
            updated = 0
            for item in self.param_tree.get_children():
                orig = self.param_tree.set(item, 'param')
                norm_orig = normalize_param_name(orig)
                if norm_orig in param_map:
                    self.param_tree.set(item, 'translation', param_map[norm_orig])
                    updated += 1
            self.info_label.config(text=f'Импортировано из {file}. Обновлено переводов: {updated}')
            self.init_param_translations_from_tree()
            self.save_param_translations()
            self.auto_save_profile()
            self.fill_main_table_from_characteristics()
            # --- Применяем все правила объединения параметров ---
            if hasattr(self, 'param_merge_rules') and self.param_merge_rules:
                for rule in self.param_merge_rules:
                    params = rule.get('params') or rule.get('parameters')
                    target = rule.get('target')
                    if params and target:
                        self.perform_param_merge(params, target, apply_only=True)
            self.setup_parameters_tab()
            self.update_replace_col_menu()  # <--- ДОБАВЛЕНО

    def setup_typos_tab(self):
        for widget in self.typos_tab.winfo_children():
            widget.destroy()
        Label(self.typos_tab, text="Пользовательские правила исправления опечаток", font=("Arial", 12, "bold")).pack(pady=10)
        from tkinter import ttk, simpledialog, messagebox
        frame = Frame(self.typos_tab)
        frame.pack(fill="both", expand=True, padx=10, pady=5)
        columns = ("active", "orig", "suggest", "edit", "delete")
        tree = ttk.Treeview(frame, columns=columns, show="headings", height=15)
        tree.heading("active", text="Активно")
        tree.heading("orig", text="Исходное значение")
        tree.heading("suggest", text="Вариант замены")
        tree.heading("edit", text="Редактировать")
        tree.heading("delete", text="Удалить")
        tree.column("active", width=70, anchor='center')
        tree.column("orig", width=220)
        tree.column("suggest", width=220)
        tree.column("edit", width=100, anchor='center')
        tree.column("delete", width=100, anchor='center')
        tree.pack(fill="both", expand=True, side=LEFT)
        self.typo_rule_vars = []
        for idx, rule in enumerate(self.typo_rules):
            active = rule.get('active', True)
            tree.insert("", "end", iid=str(idx), values=("✔" if active else "", rule['orig'], rule['suggest'], "Редактировать", "Удалить"))
            self.typo_rule_vars.append(active)
        def on_tree_click(event):
            item = tree.identify_row(event.y)
            col = tree.identify_column(event.x)
            if not item:
                return
            idx = int(item)
            if col == '#4':  # Редактировать
                rule = self.typo_rules[idx]
                new_orig = simpledialog.askstring("Редактировать исходное значение", "Новое исходное значение:", initialvalue=rule['orig'])
                if new_orig is None:
                    return
                new_suggest = simpledialog.askstring("Редактировать вариант замены", "Новый вариант замены:", initialvalue=rule['suggest'])
                if new_suggest is None:
                    return
                self.typo_rules[idx]['orig'] = new_orig
                self.typo_rules[idx]['suggest'] = new_suggest
                self.auto_save_profile()
                self.setup_typos_tab()
            elif col == '#5':  # Удалить
                if messagebox.askyesno("Удалить правило", "Удалить это правило?"):
                    self.typo_rules.pop(idx)
                    self.auto_save_profile()
                    self.setup_typos_tab()
            elif col == '#1':  # Активно (чекбокс)
                self.typo_rules[idx]['active'] = not self.typo_rules[idx].get('active', True)
                self.auto_save_profile()
                self.setup_typos_tab()
        tree.bind('<Button-1>', on_tree_click)
        def add_rule():
            orig = simpledialog.askstring("Новое правило", "Исходное значение:")
            if not orig:
                return
            suggest = simpledialog.askstring("Новое правило", "Вариант замены:")
            if suggest is None:
                return
            self.typo_rules.append({'orig': orig, 'suggest': suggest, 'active': True})
            self.auto_save_profile()
            self.setup_typos_tab()
        Button(self.typos_tab, text="Добавить правило", command=add_rule).pack(pady=10)
        def apply_typos():
            if not hasattr(self, 'current_df') or self.current_df is None:
                return
            df = self.current_df.copy()
            for rule in self.typo_rules:
                if rule.get('active', True):
                    orig = rule['orig']
                    suggest = rule['suggest']
                    if orig:
                        for col in df.columns:
                            if col == 'Артикул':
                                continue
                            df[col] = df[col].astype(str).apply(lambda x: x.replace(orig, suggest))
            self.current_df = df
            self.refresh_main_table_from_df()
        Button(self.typos_tab, text="Применить выбранные", command=apply_typos).pack(pady=10)

    def update_replace_col_menu(self):
        # Обновляет выпадающий список столбцов с учетом переводов и порядка основной таблицы
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        if hasattr(self, 'replace_col_menu'):
            translations = getattr(self, 'param_translations', {})
            columns = list(self.current_df.columns)
            extra_cols = set(self.settings.get('extra_cols', []))
            service_cols = ['Артикул исходный', 'Артикул модели']
            # Сортируем параметры по переводу
            def get_translation(param):
                norm = normalize_param_name(param)
                return translations.get(norm) or translations.get(param) or param
            # Основные параметры (без служебных и доп. столбцов)
            param_cols = [col for col in columns if col not in service_cols and col not in extra_cols]
            param_cols_sorted = sorted(param_cols, key=lambda p: get_translation(p).lower())
            # Итоговый порядок: служебные, параметры по алфавиту перевода, доп. столбцы
            ordered_cols = [col for col in service_cols if col in columns] + param_cols_sorted + [col for col in columns if col in extra_cols]
            menu_values = [(get_translation(col), col) for col in ordered_cols]
            self.replace_col_menu_map = {tr: orig for tr, orig in menu_values}
            self.replace_col_menu['values'] = [tr for tr, orig in menu_values]
            if menu_values:
                self.replace_col_var.set(menu_values[0][0])
            def on_replace_col_selected(event=None):
                tr = self.replace_col_var.get()
                orig = self.replace_col_menu_map.get(tr, tr)
                self.replace_col_var.set(orig)
            self.replace_col_menu.bind('<<ComboboxSelected>>', on_replace_col_selected)
            self.replace_col_menu.update_idletasks()

    def export_to_csv(self):
        import pandas as pd
        from tkinter import filedialog
        if not hasattr(self, 'current_df') or self.current_df is None:
            self.info_label.config(text='Нет данных для экспорта')
            return
        file = filedialog.asksaveasfilename(defaultextension='.csv', filetypes=[('CSV files', '*.csv')])
        if file:
            self.current_df.to_csv(file, index=False, encoding='utf-8-sig')
            self.info_label.config(text=f'Экспортировано в {file}')

    def save_column_history(self):
        # Сохраняет историю изменений столбцов
        data = {
            'column_history': self.column_history,
            'column_op_history': self.column_op_history,
            'column_history_active': getattr(self, 'column_history_active', {})
        }
        with open('column_history.json', 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

    def load_column_history(self):
        # Загружает историю изменений столбцов
        import os
        if os.path.exists('column_history.json'):
            with open('column_history.json', 'r', encoding='utf-8') as f:
                data = json.load(f)
            self.column_history = data.get('column_history', {})
            self.column_op_history = data.get('column_op_history', {})
            self.column_history_active = data.get('column_history_active', {})
        else:
            self.column_history = {}
            self.column_op_history = {}
            self.column_history_active = {}
        # --- Делаем все правила активными ---
        for col, ops in self.column_op_history.items():
            self.column_history_active[col] = [True] * len(ops)
        # --- Не вызываем пересчёт здесь, чтобы избежать ошибки при отсутствии данных ---

    def parse_td_lines(self, td):
        lines = []
        for content in td.contents:
            tag_name = getattr(content, 'name', None)
            # Разделитель для <br>, <p>, <div>, <li>
            if tag_name in ('br', 'p', 'div', 'li'):
                lines.append('\n')
                if tag_name != 'br':
                    # Для <p>, <div>, <li> — добавить их текст
                    lines.append(content.get_text(separator=' ', strip=True))
            else:
                if isinstance(content, str):
                    lines.append(content)
                else:
                    lines.append(content.get_text(separator=' ', strip=True))
        full_text = ''.join(lines)
        lines_cleaned = [line.strip() for line in full_text.split('\n') if line.strip()]
        return lines_cleaned

    def recalc_column_from_history(self, real_col):
        import re
        # 1. Взять оригинальные значения
        if hasattr(self, '_pristine_table_df') and real_col in self._pristine_table_df.columns:
            col_data = list(self._pristine_table_df[real_col])
            orig_index = self._pristine_table_df.index
        elif hasattr(self, '_pristine_df') and real_col in self._pristine_df.columns:
            col_data = list(self._pristine_df[real_col])
            orig_index = self._pristine_df.index
        elif hasattr(self, 'original_df') and real_col in self.original_df.columns:
            col_data = list(self.original_df[real_col])
            orig_index = self.original_df.index
        else:
            self.info_label.config(text=f'Столбец {real_col} не найден в исходных данных')
            return
        # 2. Применить только активные операции (True = активно)
        ops = self.column_op_history.get(real_col, [])
        active_flags = self.column_history_active.get(real_col, [True]*len(ops))
        for idx, (op, active) in enumerate(zip(ops, active_flags)):
            if not active:
                continue  # Только если True — применяем правило
            m = re.search(r"Замена: '(.+)' → '(.*)' \(regex: (True|False)\)", op)
            if m:
                find, replace, regex = m.group(1), m.group(2), m.group(3) == "True"
                if regex:
                    col_data = [re.sub(find, replace, str(x), flags=re.IGNORECASE) for x in col_data]
                else:
                    col_data = [str(x).replace(find, replace) for x in col_data]
        # 3. Обновить столбец только по совпадающим индексам
        import pandas as pd
        if len(col_data) == len(self.current_df):
            self.current_df[real_col] = col_data
        else:
            # Присваиваем только по совпадающим индексам
            s = pd.Series(col_data, index=orig_index)
            for idx in self.current_df.index:
                if idx in s.index:
                    self.current_df.at[idx, real_col] = s.at[idx]
        self.refresh_main_table_from_df()

    def toggle_column_history_op(self, col, idx, value, dialog=None, real_col=None):
        if col in self.column_history_active:
            self.column_history_active[col][idx] = value
        self.recalc_column_from_history(col)
        self.save_column_history()
        self.auto_save_profile()
        if dialog is not None and real_col is not None:
            dialog.destroy()
            self.open_column_history_dialog(real_col)

    def on_value_translation_toggle(self, idx):
        # Этот метод должен вызываться при изменении активности правила перевода значений
        if idx < len(self.value_translation_rules):
            rule = self.value_translation_rules[idx]
            rule['active'] = not rule.get('active', True)
            self.save_value_translation_rules()
            self.apply_value_translation_rules()
            self.refresh_main_table_from_df()  # Обновляем таблицу после изменения

    def save_profile(self):
        import json
        from tkinter import simpledialog, filedialog
        profile_name = simpledialog.askstring("Сохранить профиль", "Введите имя профиля:")
        if not profile_name:
            return
        # Сохраняем в папку profiles
        file = filedialog.asksaveasfilename(
            defaultextension='.json', 
            initialfile=f'{profile_name}.json', 
            filetypes=[('JSON files', '*.json')],
            initialdir=self.profiles_dir
        )
        if file:
            profile = {
                'param_translations': self.param_translations,
                'column_history': getattr(self, 'column_history', {}),
                'column_op_history': getattr(self, 'column_op_history', {}),
                'column_history_active': getattr(self, 'column_history_active', {}),
                'visible_columns': getattr(self, 'visible_columns', []),
                'settings': getattr(self, 'settings', {}),
                'last_file': getattr(self, 'last_file', ''),
                'value_translation_rules': getattr(self, 'value_translation_rules', []),
                'param_merge_rules': getattr(self, 'param_merge_rules', []),
                'typo_rules': getattr(self, 'typo_rules', []),
            }
            with open(file, 'w', encoding='utf-8') as f:
                json.dump(profile, f, ensure_ascii=False, indent=2)
            # Устанавливаем новый профиль как текущий
            self.current_profile_path = file
            with open('last_profile.txt', 'w', encoding='utf-8') as f:
                f.write(file)
            self.info_label.config(text=f'Профиль сохранён: {file}')
            # Обновляем список профилей в выпадающем списке
            self.update_profiles_list()

    def load_profile(self):
        import json
        from tkinter import filedialog
        file = filedialog.askopenfilename(
            filetypes=[('JSON files', '*.json')],
            initialdir=self.profiles_dir
        )
        if not file:
            return
        with open(file, 'r', encoding='utf-8') as f:
            profile = json.load(f)
        self.param_translations = profile.get('param_translations', {})
        self.column_history = profile.get('column_history', {})
        self.column_op_history = profile.get('column_op_history', {})
        self.column_history_active = profile.get('column_history_active', {})
        self.visible_columns = profile.get('visible_columns', [])
        self.settings = profile.get('settings', {})
        self.last_file = profile.get('last_file', '')
        self.value_translation_rules = profile.get('value_translation_rules', [])
        # Если нет правил в профиле — подгружаем из файла
        if not self.value_translation_rules:
            try:
                with open('value_translation_rules.json', encoding='utf-8') as f:
                    self.value_translation_rules = json.load(f)
            except Exception:
                self.value_translation_rules = []
        self.param_merge_rules = profile.get('param_merge_rules', [])
        self.typo_rules = profile.get('typo_rules', [])
        # Устанавливаем загруженный профиль как текущий
        self.current_profile_path = file
        self.save_param_translations()
        self.refresh_main_table_headers_with_translations()
        self.update_replace_col_menu()
        self.setup_values_translation_tab()
        # Обновляем вкладку параметров с загруженными переводами
        if hasattr(self, 'current_df') and self.current_df is not None:
            self.setup_parameters_tab()
        # --- Применяем правила объединения параметров если есть данные ---
        if hasattr(self, 'current_df') and self.current_df is not None:
            for rule in self.param_merge_rules:
                params = rule.get('params') or rule.get('parameters')
                target = rule.get('target')
                if params and target:
                    self.perform_param_merge(params, target, apply_only=True)
            # --- Применяем историю столбцов ---
            for col in self.column_op_history:
                if col in self.current_df.columns:
                    self.recalc_column_from_history(col)
        with open('last_profile.txt', 'w', encoding='utf-8') as f:
            f.write(file)
        self.info_label.config(text=f'Профиль загружен: {file}')
        # Обновляем список профилей в выпадающем списке
        self.update_profiles_list()

    def auto_load_last_profile(self):
        import os, json
        if os.path.exists('last_profile.txt'):
            with open('last_profile.txt', 'r', encoding='utf-8') as f:
                file = f.read().strip()
            if file and os.path.exists(file):
                with open(file, 'r', encoding='utf-8') as pf:
                    profile = json.load(pf)
                self.param_translations = profile.get('param_translations', {})
                self.column_history = profile.get('column_history', {})
                self.column_op_history = profile.get('column_op_history', {})
                self.column_history_active = profile.get('column_history_active', {})
                self.visible_columns = profile.get('visible_columns', [])
                self.settings = profile.get('settings', {})
                self.last_file = profile.get('last_file', '')
                self.value_translation_rules = profile.get('value_translation_rules', [])
                # Если нет правил в профиле — подгружаем из файла
                if not self.value_translation_rules:
                    try:
                        with open('value_translation_rules.json', encoding='utf-8') as f:
                            self.value_translation_rules = json.load(f)
                    except Exception:
                        self.value_translation_rules = []
                self.param_merge_rules = profile.get('param_merge_rules', [])
                self.typo_rules = profile.get('typo_rules', [])
                # Устанавливаем загруженный профиль как текущий
                self.current_profile_path = file
                self.save_param_translations()
                self.refresh_main_table_headers_with_translations()
                self.update_replace_col_menu()
                self.setup_values_translation_tab()
                # --- Применяем правила объединения параметров если есть данные ---
                if hasattr(self, 'current_df') and self.current_df is not None:
                    # Создаем pristine_table_df для применения истории
                    if not hasattr(self, '_pristine_table_df'):
                        self._pristine_table_df = self.current_df.copy(deep=True)
                    for rule in self.param_merge_rules:
                        params = rule.get('params') or rule.get('parameters')
                        target = rule.get('target')
                        if params and target:
                            self.perform_param_merge(params, target, apply_only=True)
                        # --- Применяем историю столбцов ---
                        for col in self.column_op_history:
                            if col in self.current_df.columns:
                                self.recalc_column_from_history(col)
                
                self.info_label.config(text=f'Профиль автоматически загружен: {file}')
                # Обновляем список профилей в выпадающем списке
                self.update_profiles_list()

    # --- Автосохранение переводов при изменении ---
    def on_param_translation_edit(self, event=None):
        # ... существующий код редактирования ...
        self.save_param_translations()

    def normalize_colname(self, name):
        # Убирает пробелы, неразрывные пробелы, дефисы, подчёркивания, приводит к нижнему регистру
        return name.strip().replace('\xa0', ' ').replace('\u00a0', ' ').replace('-', ' ').replace('_', ' ').replace('  ', ' ').lower()

    def normalize_colname_for_treeview(self, name):
        # Нормализует имена столбцов для Treeview (транслитерация + безопасные символы)
        import re
        normalized = str(name)
        normalized = normalized.replace('\xa0', ' ').replace('\u00a0', ' ')
        normalized = re.sub(r'\s+', '_', normalized)
        
        # Простая транслитерация кириллицы
        translit_map = {
            'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
            'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
            'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
            'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
            'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
            'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'YO',
            'Ж': 'ZH', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M',
            'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U',
            'Ф': 'F', 'Х': 'H', 'Ц': 'TS', 'Ч': 'CH', 'Ш': 'SH', 'Щ': 'SCH',
            'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'YU', 'Я': 'YA'
        }
        
        # Применяем транслитерацию
        for cyr, lat in translit_map.items():
            normalized = normalized.replace(cyr, lat)
        
        # Заменяем оставшиеся спецсимволы на подчёркивания
        normalized = re.sub(r'[^a-zA-Z0-9_]', '_', normalized)
        normalized = re.sub(r'_+', '_', normalized)
        normalized = normalized.strip('_')
        if not normalized:
            normalized = 'unnamed_column'
        # Убеждаемся, что имя начинается с буквы
        if normalized and not normalized[0].isalpha():
            normalized = 'col_' + normalized
        return normalized

    def drop_duplicate_columns_by_normalized(self, df):
        seen = set()
        keep = []
        for col in df.columns:
            norm = self.normalize_colname(col)
            if norm not in seen:
                seen.add(norm)
                keep.append(col)
        return df[keep]

    def filter_unique_columns_by_normalized(self, columns):
        seen = set()
        result = []
        for col in columns:
            norm = self.normalize_colname(col)
            if norm not in seen:
                seen.add(norm)
                result.append(col)
        return result

    def update_profiles_list(self):
        """Обновляет список профилей в выпадающем списке"""
        import os
        import glob
        from datetime import datetime
        
        # Получаем все JSON файлы из папки profiles
        profile_files = glob.glob(os.path.join(self.profiles_dir, '*.json'))
        
        # Создаем список профилей с информацией о дате изменения
        profiles_list = []
        for file_path in profile_files:
            try:
                # Получаем имя файла без расширения
                filename = os.path.basename(file_path)
                name = os.path.splitext(filename)[0]
                
                # Получаем дату изменения файла
                mtime = os.path.getmtime(file_path)
                date_str = datetime.fromtimestamp(mtime).strftime('%d.%m.%Y %H:%M')
                
                # Добавляем в список
                profiles_list.append(f"{name} ({date_str})")
            except Exception:
                continue
        
        # Сортируем по дате изменения (новые сверху)
        profiles_list.sort(key=lambda x: x.split('(')[1].split(')')[0], reverse=True)
        
        # Добавляем опцию "Нет профиля"
        profiles_list.insert(0, "Нет профиля")
        
        # Обновляем выпадающий список
        self.profile_combo['values'] = profiles_list
        
        # Устанавливаем текущий профиль
        if self.current_profile_path:
            try:
                filename = os.path.basename(self.current_profile_path)
                name = os.path.splitext(filename)[0]
                mtime = os.path.getmtime(self.current_profile_path)
                date_str = datetime.fromtimestamp(mtime).strftime('%d.%m.%Y %H:%M')
                current_profile = f"{name} ({date_str})"
                if current_profile in profiles_list:
                    self.profile_var.set(current_profile)
                else:
                    self.profile_var.set("Нет профиля")
            except Exception:
                self.profile_var.set("Нет профиля")
        else:
            self.profile_var.set("Нет профиля")

    def on_profile_selected(self, event=None):
        """Обработчик выбора профиля из выпадающего списка"""
        selected = self.profile_var.get()
        if selected == "Нет профиля":
            return
        
        # Извлекаем имя файла из выбранного элемента
        filename = selected.split(' (')[0] + '.json'
        file_path = os.path.join(self.profiles_dir, filename)
        
        if os.path.exists(file_path):
            # Загружаем профиль
            self.load_profile_by_path(file_path)
        else:
            from tkinter import messagebox
            messagebox.showerror("Ошибка", f"Файл профиля не найден:\n{file_path}")
            self.update_profiles_list()

    def load_profile_by_path(self, file_path):
        """Загружает профиль по указанному пути"""
        import json
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                profile = json.load(f)
            
            self.param_translations = profile.get('param_translations', {})
            self.column_history = profile.get('column_history', {})
            self.column_op_history = profile.get('column_op_history', {})
            self.column_history_active = profile.get('column_history_active', {})
            self.visible_columns = profile.get('visible_columns', [])
            self.settings = profile.get('settings', {})
            self.last_file = profile.get('last_file', '')
            self.value_translation_rules = profile.get('value_translation_rules', [])
            # Если нет правил в профиле — подгружаем из файла
            if not self.value_translation_rules:
                try:
                    with open('value_translation_rules.json', encoding='utf-8') as f:
                        self.value_translation_rules = json.load(f)
                except Exception:
                    self.value_translation_rules = []
            self.param_merge_rules = profile.get('param_merge_rules', [])
            self.typo_rules = profile.get('typo_rules', [])
            
            # Устанавливаем загруженный профиль как текущий
            self.current_profile_path = file_path
            
            self.save_param_translations()
            self.refresh_main_table_headers_with_translations()
            self.update_replace_col_menu()
            self.setup_values_translation_tab()
            
            # Обновляем вкладку параметров с загруженными переводами
            if hasattr(self, 'current_df') and self.current_df is not None:
                self.setup_parameters_tab()
            
            # --- Применяем правила объединения параметров если есть данные ---
            if hasattr(self, 'current_df') and self.current_df is not None:
                # Создаем pristine_table_df для применения истории
                if not hasattr(self, '_pristine_table_df'):
                    self._pristine_table_df = self.current_df.copy(deep=True)
                for rule in self.param_merge_rules:
                    params = rule.get('params') or rule.get('parameters')
                    target = rule.get('target')
                    if params and target:
                        self.perform_param_merge(params, target, apply_only=True)
                # --- Применяем историю столбцов ---
                for col in self.column_op_history:
                    if col in self.current_df.columns:
                        self.recalc_column_from_history(col)
            
            with open('last_profile.txt', 'w', encoding='utf-8') as f:
                f.write(file_path)
            
            self.info_label.config(text=f'Профиль загружен: {os.path.basename(file_path)}')
            
        except Exception as e:
            from tkinter import messagebox
            messagebox.showerror("Ошибка загрузки профиля", f"Не удалось загрузить профиль:\n{str(e)}")

    def load_settings(self):
        self.settings = load_settings(self.SETTINGS_FILE)

    def apply_value_translation_rules(self):
        # Применяет активные правила value_translation_rules ко всем значениям таблицы
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        import pandas as pd
        import re
        df = self.current_df.copy()
        active_rules = [r for r in self.value_translation_rules if r.get('active', True)]
        if not active_rules:
            self.current_df = df.fillna('')
            return
        skip_cols = {'Артикул исходный', 'Артикул модели'} | set(self.settings.get('extra_cols', []))
        param_cols = [c for c in df.columns if c not in skip_cols]
        def normalize_value(val):
            val = str(val)
            val = val.replace("'", '"').replace("''", '"').replace("`", '"').replace("ʼ", '"')
            val = val.replace(""", '"').replace(""", '"').replace("«", '"').replace("»", '"')
            val = val.replace("'", '"')
            val = re.sub(r'\s+', ' ', val)
            val = re.sub(r'\s*([,;/])\s*', r'\1 ', val)
            val = re.sub(r'\s*([~\-])\s*', r' \1 ', val)
            val = re.sub(r'\s+', ' ', val)
            val = val.strip()
            return val
        short_units = [r.get('original').strip() for r in active_rules if r.get('original') and len(r.get('original').strip()) <= 4]
        for col in param_cols:
            for idx, v in df[col].items():
                orig_val = str(v)
                new_val = orig_val
                for unit in short_units:
                    if unit:
                        new_val = re.sub(rf'(\d)\s+({re.escape(unit)})\b', r'\1\2', new_val)
                for rule in active_rules:
                    orig = rule.get('original')
                    trans = rule.get('translation')
                    if not orig or not trans:
                        continue
                    orig = orig.strip()
                    if len(orig) <= 4:
                        # Заменяем и с пробелом, и без пробела между числом и единицей
                        pattern = rf'(\d+(?:\.\d+)?)(\s*){re.escape(orig)}\b'
                        new_val2 = re.sub(pattern, r'\1' + trans, new_val)
                        # Также заменяем просто standalone 'mm'
                        pattern2 = rf'\b{re.escape(orig)}\b'
                        new_val2 = re.sub(pattern2, trans, new_val2)
                        if new_val2 != new_val:
                            print(f"[DEBUG] {col}: '{new_val}' -> '{new_val2}' по правилу '{orig}' → '{trans}' (короткое)")
                        new_val = new_val2
                    else:
                        pattern = re.escape(orig)
                        new_val2 = re.sub(pattern, trans, new_val)
                        if new_val2 != new_val:
                            print(f"[DEBUG] {col}: '{new_val}' -> '{new_val2}' по правилу '{orig}' → '{trans}' (длинное)")
                        new_val = new_val2
                # --- После всех замен ещё раз сливаем число и единицу (и перевод) ---
                all_units = set(short_units)
                all_units.update([r.get('translation').strip() for r in active_rules if r.get('translation') and len(r.get('translation').strip()) <= 4])
                for unit in all_units:
                    if unit:
                        new_val = re.sub(rf'(\d)\s+({re.escape(unit)})\b', r'\1\2', new_val)
                # --- Финальная нормализация значения для таблицы ---
                new_val = normalize_value_format(new_val)
                df.at[idx, col] = new_val
        self.current_df = df.fillna('')

def normalize_param_name(name):
    import re
    name = re.sub(r'[-_]', ' ', name)
    name = re.sub(r'\s+', ' ', name)
    name = name.strip().lower()
    return name

def normalize_value(val):
    # Привести все кавычки к обычным двойным
    val = val.replace("'", '"').replace("'", '"').replace("''", '"').replace("`", '"').replace("ʼ", '"')
    val = val.replace(""", '"').replace(""", '"').replace("«", '"').replace("»", '"')
    val = val.replace("'", '"')
    # Удалить лишние пробелы вокруг знаков препинания и внутри строки
    import re
    val = re.sub(r'\s+', ' ', val)
    val = re.sub(r'\s*([,;/])\s*', r'\1 ', val)
    val = val.strip()
    return val

def remove_duplicates_normalized(values):
    seen = set()
    result = []
    for v in values:
        norm = normalize_value(v)
        if norm not in seen:
            seen.add(norm)
            result.append(norm)
    return result

def get_param_translation(col_name, param_translations):
    """
    Возвращает перевод параметра по нормализованному имени.
    Если не найдено — возвращает None.
    """
    norm_col = normalize_param_name(col_name)
    for key, value in (param_translations or {}).items():
        if normalize_param_name(key) == norm_col:
            return value
    return None

root = tk.Tk()

def global_tk_exception_handler(exc, val, tb):
    import traceback
    err_msg = ''.join(traceback.format_exception(exc, val, tb))
    print('Глобальная ошибка Tkinter:', err_msg)
    try:
        # Если info_label уже создан — показать ошибку пользователю
        if hasattr(app, 'info_label'):
            app.info_label.config(text=f'Ошибка: {val}')
    except Exception:
        pass

root.report_callback_exception = global_tk_exception_handler

import traceback
from tkinter import messagebox
try:
    app = AttributeEditor(root)
    root.mainloop()
except Exception as e:
    print(f"Ошибка при запуске приложения: {e}")
 
когда я снимаю галочку в чекбоксе в окне "Выбор дополнительных столбцов", появляется ошибка:
Traceback (most recent call last):
  File "c:\Users\Vshmyglyuk\Desktop\rab\main.py", line 709, in fill_main_table_from_characteristics
    self.refresh_main_table_from_df()
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "c:\Users\Vshmyglyuk\Desktop\rab\main.py", line 1594, in refresh_main_table_from_df
    self.tree['columns'] = normalized_columns
    ~~~~~~~~~^^^^^^^^^^^
  File "C:\Users\Vshmyglyuk\AppData\Local\Programs\Python\Python313\Lib\tkinter\__init__.py", line 1833, in __setitem__
    self.configure({key: value})
    ~~~~~~~~~~~~~~^^^^^^^^^^^^^^
  File "C:\Users\Vshmyglyuk\AppData\Local\Programs\Python\Python313\Lib\tkinter\__init__.py", line 1822, in configure
    return self._configure('configure', cnf, kw)
           ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Vshmyglyuk\AppData\Local\Programs\Python\Python313\Lib\tkinter\__init__.py", line 1812, in _configure
    self.tk.call(_flatten((self._w, cmd)) + self._options(cnf))
    ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_tkinter.TclError: Invalid column index Artikul

Исправь мой код, чтобы не было ошибок и напиши мне полный код приложения.