Загрузка данных


#  Dozor\gui\main_interface.py

import os
import tkinter as tk
from typing import SupportsIndex 
from tkinter import ttk
from PIL import Image, ImageTk
from openpyxl import Workbook
from pathlib import Path
from modules.administrator.admin_ui import AdminPanel


class MainInterface(tk.Tk):
    def __init__(self):
        super().__init__()
        self.current_user = {"login": None}
        
        self.geometry("458x360")
        self.resizable(False, False)
        bg_color = "#EEEEEE"
        self.configure(bg=bg_color)
        
        main_frame = tk.Frame(self, bg=bg_color)
        main_frame.pack(padx=1, pady=1, fill='both', expand=True)
        
        button_width = 20
        button_height = 2
        button_font = ("Arial", 12, "bold")
        
        for i in range(7):
            main_frame.grid_rowconfigure(i, weight=0)
            main_frame.grid_columnconfigure(0, minsize=210, weight=0)
            main_frame.grid_columnconfigure(1, minsize=235, weight=0)

        # --- ЛЕВАЯ ПАНЕЛЬ ---
        left_buttons = [
            ("Администратор", self._show_admin_menu),
            ("Менеджер базы", self._show_manager_menu), 
            ("Эксперты", self._show_experts_menu),
            ("Проект", self._show_project_menu),
            ("Прогнозирование", self._show_forecast_menu)
        ]
        
        for row_index, (btn_text, command_func) in enumerate(left_buttons):
            btn = tk.Button(
                main_frame,
                text=btn_text,
                font=button_font,
                width=button_width,
                height=button_height,
                anchor="w",
                command=command_func
            )
            btn.grid(row=row_index, column=0, padx=5, pady=5, sticky="ew")

        # --- ПРАВАЯ ПАНЕЛЬ (СОВА) ---
        right_box = tk.Frame(main_frame, bg=bg_color, width=235, height=300)
        right_box.grid(row=0, column=1, rowspan=6, pady=5, sticky="nsew", padx=(0, 5))
        right_box.grid_propagate(False)
        right_box.columnconfigure(0, weight=1)
        
        picture_frame = tk.Frame(right_box, bg="#d9ead3", highlightbackground="gray",
                                 highlightthickness=1, width=225, height=160)
        picture_frame.place(relx=0.5, y=5, anchor="n")
        picture_frame.grid_propagate(False)
        
        current_dir = os.path.dirname(os.path.abspath(__file__))
        image_path = os.path.join(current_dir, "images", "Сова1.png")
        try:
            original_img = Image.open(image_path)
            target_w, target_h = 215, 130
            img_ratio = original_img.width / original_img.height
            box_ratio = target_w / target_h
            if img_ratio > box_ratio:
                new_w, new_h = target_w, int(target_w / img_ratio)
            else:
                new_w, new_h = int(target_h * img_ratio), target_h
            resized_img = original_img.resize((new_w, new_h), resample=Image.LANCZOS)
            self.tk_image = ImageTk.PhotoImage(resized_img)
            label = tk.Label(picture_frame, image=self.tk_image, bg="#d9ead3", borderwidth=0)
            label.image = self.tk_image
            label.place(relx=0.5, rely=0.5, anchor='center', width=new_w, height=new_h)
        except Exception as e:
            print(f"[FATAL IMAGE ERROR]: {e}")
            canvas = tk.Canvas(picture_frame, width=225, height=160, bg="red", highlightthickness=0)
            canvas.create_text(112, 80, text="IMG\nFAIL", fill="white")
            canvas.place(x=0, y=0, relwidth=1, relheight=1)
            
        spacer = tk.Frame(right_box, bg=bg_color, height=1)
        spacer.grid(row=0, column=0, pady=(170, 10), sticky="ew")
        
        upper_padding = tk.Frame(right_box, bg=bg_color, height=3)
        upper_padding.grid(row=1, column=0)
        
        btn6 = tk.Button(right_box, text="Руководство", font=button_font, width=button_width, height=button_height)
        btn6.grid(row=2, column=0, ipadx=10, pady=(0, 5), sticky="ew")
        btn7 = tk.Button(right_box, text="О программе", font=button_font, width=button_width, height=button_height)
        btn7.grid(row=3, column=0, ipadx=10, pady=(5, 15), sticky="ew")

        footer_frame = tk.Frame(main_frame, bg="#0078D7")
        footer_frame.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(10, 0))
        footer_label = tk.Label(footer_frame, text="Copyright © Минск, НИИ, 2027 год", fg="#FF8C00",
                                bg="#0078D7", font=("Arial", 12, "bold"))
        footer_label.pack(expand=True, ipady=4)

        # --- КОНТЕЙНЕРЫ ДЛЯ ПОДМЕНЮ ---
        self.menu_container = tk.Frame(main_frame, bg=bg_color)
        self.menu_container.grid(row=0, column=0, rowspan=6, columnspan=2, sticky="nsew")
        self.menu_container.grid_rowconfigure(0, weight=1)
        self.menu_container.grid_columnconfigure(0, weight=1)
        
        # Убираем старые заглушки, оставляем только Админа и Менеджера
        self.admin_panel = None
        self.manager_panel = None
        
        self._current_menu_visible = None

    # --- МЕТОД ВОЗВРАТА НА ГЛАВНЫЙ ЭКРАН ---
    def _show_main_screen(self):
        self._hide_current_menu()
        self.menu_container.grid_remove()
        self.grid_columnconfigure(0, minsize=210, weight=0)
        self.grid_columnconfigure(1, minsize=235, weight=0)

    def _hide_current_menu(self):
        print("[LOG] Скрываем текущее меню...")
        
        # Добавили удаление фасада Менеджера базы
        if hasattr(self, 'manager_panel') and self.manager_panel is not None:
            self.manager_panel.destroy()
            self.manager_panel = None

        # Оставили удаление фасада Администратора (он уже был)
        if hasattr(self, 'admin_panel') and self.admin_panel is not None:
            self.admin_panel.destroy()
            self.admin_panel = None

        # Очистка контейнера с серыми заглушками (на всякий случай)
        for child in self.menu_container.winfo_children():
            child.destroy()
            
        self._current_menu_visible = None

    def _show_admin_menu(self):
        print("[LOG] Нажата кнопка 'Администратор'.")
        self._hide_current_menu()
        try:
            from modules.administrator.admin_ui import AdminPanel
            self.admin_panel = AdminPanel(self, current_user=self.current_user)
            self.admin_panel.place(relx=0, rely=0, relwidth=1, relheight=1)
            self.admin_panel.lift()
            self.update_idletasks()
            self._current_menu_visible = 'admin'
        except Exception as e:
            print(f"[FATAL ERROR] Не удалось создать панель администратора: {e}")

    def _show_manager_menu(self):
        print("[LOG] Нажата кнопка 'Менеджер базы'.")
        
        # Прячем всё старое одним махом
        self._hide_current_menu()
        
        try:
            from modules.manager.manager_ui import ManagerPanel
            
            # Создаем панель ПРЯМО В ГЛАВНОМ ОКНЕ (self), а не в menu_container
            self.manager_panel = ManagerPanel(self, current_user=self.current_user)
            
            # Кладем её НАД ВСЕМ (поверх левой панели, правой панели и картинки)
            self.manager_panel.place(relx=0, rely=0, relwidth=1, relheight=1)
            
            # Принудительно просим поднять Z-index именно этой панели
            self.manager_panel.lift()
            
            # Заставляем Tkinter перерисоваться СЕЙЧАС
            self.update_idletasks()
            
            self._current_menu_visible = 'manager'
            print("[LOG] Фасад Менеджера успешно размещен.")
            
        except Exception as e:
            # Теперь мы точно увидим ошибку, если она есть
            print(f"[FATAL CRASH IN MANAGER MENU] {e}")

        # Остальные кнопки пока просто закрывают оверлей (возвращают к сове)
    def _show_experts_menu(self):
        print("[LOG] Нажата кнопка 'Эксперты'.")
        self._hide_current_menu()
        
        try:
            from modules.excel.excel_ui import ExpertsFacade
            
            # Создаем как обычное дочернее окно (точно так же, как AdminPanel)
            self.experts_panel = ExpertsFacade(self, current_user=self.current_user)
            
            # Регистрируем для системы управления окнами (если есть)
            if hasattr(self, 'register_child_window'):
                self.register_child_window(self.experts_panel, offset_x=20, offset_y=20)
                
            # Ждем закрытия (модальность). Пока эксперты открыты, главную кнопку нажать нельзя.
            self.wait_window(self.experts_panel)
            
            self._current_menu_visible = 'experts'
            
        except Exception as e:
            print(f"[FATAL ERROR] Не удалось создать панель экспертов: {e}")

    def _show_project_menu(self):
        self._show_main_screen()

    def _show_forecast_menu(self):
        self._show_main_screen()

    def _show_guide_menu(self):
        self._show_main_screen()

    def _show_about_menu(self):
        self._show_main_screen()

    def switch_user_radical(self):
        root_win = self.winfo_toplevel()
        from modules.utils.app_state import disable_app
        disable_app(root_win)
        if hasattr(self, 'current_user'):
            self.current_user = {"login": None}
        if hasattr(self, '_child_windows'):
            self._child_windows.clear()
        try:
            root_win.destroy()
        except Exception:
            pass
        import sys, subprocess, os, shutil
        script_path = os.path.abspath("main.py")
        gui_python = shutil.which("pythonw.exe")
        if not gui_python:
            current_dir = os.path.dirname(sys.executable)
            potential_path = os.path.join(current_dir, "pythonw.exe")
            gui_python = potential_path if os.path.exists(potential_path) else sys.executable
        try:
            subprocess.Popen([gui_python, script_path], shell=True, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
        except Exception as e:
            print(f"[FATAL] Не удалось запустить новую копию Dozor: {e}")
        sys.exit(0)













# Dozor/modules/utils/window_behavior.py

import tkinter as tk


class LevelOneBehavior:
    """
    Поведение Главного окна (Уровень 1). Применяется к объекту класса tk.Tk.
    Движение работает ТОЛЬКО по панели заголовка (title_bar).
    """
    def setup_level_one(self, target_window):
        target_window.resizable(False, False)
        
        try:
            # Ищем панель заголовка для перетаскивания главного окна
            self.drag_widget = target_window.children['title_bar']
        except KeyError:
            self.drag_widget = target_window
            
        self.drag_widget.bind("<ButtonPress-1>", self.save_offset)
        self.drag_widget.bind("<B1-Motion>", self.drag_window)
        
        if not hasattr(target_window, '_child_windows'):
            target_window._child_windows = []
    
    def save_offset(self, event):
        self._drag_start_x = event.x_root - self.winfo_x()
        self._drag_start_y = event.y_root - self.winfo_y()

    def drag_window(self, event):
        x = event.x_root - self._drag_start_x
        y = event.y_root - self._drag_start_y
        self.geometry(f"+{x}+{y}")

    def do_nothing(self, event=None):
        pass


class LevelTwoBehavior:
    """
    ⚠️ УБРАЛ СЛОЖНУЮ ЛОГИКУ ПЕРЕТАСКИВАНИЯ ЧЕРЕЗ РОДИТЕЛЯ!
    Теперь просто центрируем окно над родителем без лишних зависимостей.
    """
    def setup_level_two(self, target_window):
        width, height = int(target_window.winfo_reqwidth()), int(target_window.winfo_reqheight())
        target_window.minsize(width, height)
        target_window.resizable(True, True)

        # Просто берем родителя окна (это наш ManagerPanel/Facade)
        parent = target_window.master

        # Вычисляем позицию центра родителя
        parent_center_x = parent.winfo_rootx() + parent.winfo_width() // 2
        parent_center_y = parent.winfo_rooty() + parent.winfo_height() // 2

        # Вычисляем размер нашего окна
        window_width = int(target_window.winfo_reqwidth())
        window_height = int(target_window.winfo_reqheight())

        # Ставим наше окно точно в центр родителя
        position_right = parent_center_x - window_width // 2
        position_down = parent_center_y - window_height // 2

        target_window.geometry(f"+{position_right}+{position_down}")

        target_window.update_idletasks()

    def do_nothing(self, event=None):
        pass


class LevelThreeBehavior:
    """Поведение окон третьего уровня (Менеджер групп). Свободное перемещение."""
    def setup_level_three(self, target_window):
        width, height = int(target_window.winfo_reqwidth()), int(target_window.winfo_reqheight())
        target_window.minsize(width, height)
        target_window.resizable(True, True)
        target_window.geometry("+0+0")

        target_window.protocol("WM_MINIMIZE_WINDOW", self.do_nothing)
        target_window.protocol("WM_DELETE_WINDOW", target_window.destroy)

        # Снимаем старые бинды
        target_window.unbind("<ButtonPress-1>")
        target_window.unbind("<B1-Motion>")

        # 1. Сохраняем точку старта. Передаем 'target_window' вторым аргументом через lambda
        target_window.bind(
            "<ButtonPress-1>",
            lambda e, tw=target_window: self.save_offset(e, tw),
            add='+'
        )

        # 2. Движение работает ТОЛЬКО если зажата клавиша Alt (маска 0x0004)
        target_window.bind(
            "<B1-Motion>",
            lambda e, tw=target_window: self.drag_free(e, tw) if e.state & 0x0004 else "",
            add='+'
        )

    def do_nothing(self, event=None):
        pass

    def save_offset(self, event, window):
        # Запоминаем ГДЕ НА ЭКРАНЕ была нажата мышь
        self._start_screen_x = event.x_root
        self._start_screen_y = event.y_root

        # Запоминаем, где В ЭТОТ МОМЕНТ было окно (его угол) ПО ЭКРАНУ
        self._start_win_x = window.winfo_rootx()
        self._start_win_y = window.winfo_rooty()

    def drag_free(self, event, window):
        dx = event.x_root - self._start_screen_x
        dy = event.y_root - self._start_screen_y
        new_x = self._start_win_x + dx
        new_y = self._start_win_y + dy
        window.geometry(f"+{new_x}+{new_y}")        










# Dozor/modules/excel/excel_aggregate_inter.py

import os
import re
from tkinter import filedialog, messagebox, ttk
import tkinter as tk
from pathlib import Path 
from modules.excel.excel_aggregate_work import AggregateWorkController
from modules.utils.window_behavior import LevelTwoBehavior


class ExpertAggregateInterface(tk.Toplevel):
    def __init__(self, parent):
        super().__init__(parent)
        
        behavior = LevelTwoBehavior()
        behavior.setup_level_two(self.winfo_toplevel())

        self.title("Агрегирование файлов")
        # Окно можно растягивать!
        self.resizable(True, True)  

        main_frame = ttk.Frame(self, padding=5)
        main_frame.pack(fill="both", expand=True, anchor="center")   

        # Настроим сетку так, чтобы она красиво растягивалась
        main_frame.columnconfigure(0, weight=1)
        main_frame.rowconfigure([2, 4], weight=1)  # Растягиваем строки с текстовыми полями

        ### Блок создания файла ###
        file_frame = ttk.Labelframe(main_frame, text="Создание файла", padding=(3, 6))
        file_frame.grid(row=0, column=0, sticky="ew") 

        ttk.Label(file_frame, text="Название:").pack(side="left")
        self.new_file_name_entry = tk.Entry(file_frame, width=20)
        self.new_file_name_entry.pack(side="left", padx=5, ipady=2)

        create_button = ttk.Button(
            file_frame,
            text="Создать",
            command=self.create_file_with_validation
        )
        create_button.pack(side="right", padx=5)

        ### Выбор целевого файла ###
        target_frame = ttk.Labelframe(main_frame, text="Целевой файл", padding=(3, 6))
        target_frame.grid(row=1, column=0, sticky="nsew")

        ttk.Label(target_frame, text="Выберите целевой файл:").pack(side="top", anchor="nw")
        self.target_file_path_entry = tk.Text(target_frame, height=1, state='disabled', wrap='none')
        self.target_file_path_entry.pack(side="left", fill="x", expand=True, pady=5)

        browse_target_button = ttk.Button(
            target_frame,
            text="Обзор...",
            command=lambda: self.select_single_file(self.target_file_path_entry, 'target')
        )
        browse_target_button.pack(side="right", padx=5)

        ### Экспертные файлы ###
        experts_frame = ttk.Labelframe(main_frame, text="Экспертные файлы", padding=(3, 6))
        experts_frame.grid(row=2, column=0, sticky="nsew")

        ttk.Label(experts_frame, text="Выберите экспертные файлы:").pack(side="top", anchor="nw")
        self.expert_files_text = tk.Text(experts_frame, height=3, state='disabled', wrap='word')
        self.expert_files_text.pack(side="left", fill="both", expand=True, pady=5)

        scroll_y = tk.Scrollbar(experts_frame, orient="vertical", command=self.expert_files_text.yview)
        scroll_y.pack(side="right", fill="y")
        self.expert_files_text.config(yscrollcommand=scroll_y.set)

        browse_experts_button = ttk.Button(
            experts_frame,
            text="Обзор...",
            command=lambda: self.select_multiple_files('experts')
        )
        browse_experts_button.pack(side="bottom", anchor="e", padx=5, pady=5)

        ### Кнопка Объединить информацию ###
        aggregate_button = ttk.Button(
            main_frame,
            text="Объединить информацию",
            style="Big.TButton",
            command=self.aggregate_data
        )
        aggregate_button.grid(row=3, column=0, sticky="ew", pady=10)

        ### Транзитивность ###
        transitivity_frame = ttk.Labelframe(main_frame, text="Транзитивность", padding=(3, 6))
        transitivity_frame.grid(row=4, column=0, sticky="nsew")

        sheet_line = ttk.Frame(transitivity_frame)
        sheet_line.pack(fill="x")

        label_sheet = ttk.Label(sheet_line, text="Лист:")
        label_sheet.pack(side="left")

        self.sheet_combobox = ttk.Combobox(sheet_line, state="readonly", width=20)
        self.sheet_combobox.pack(side="left", padx=5, ipady=2)

        check_button = ttk.Button(
            sheet_line,
            text="Проверить",
            command=self.check_transitivity
        )
        check_button.pack(side="right", padx=5)

        result_line = ttk.Frame(transitivity_frame)
        result_line.pack(fill="x", pady=5)

        self.transitivity_label = tk.Label(result_line, relief="groove", bg="#fafafa", bd=1)
        self.transitivity_label.pack(side="right", padx=5)

        ### Нижние кнопки ###
        action_buttons_frame = ttk.Frame(main_frame)
        action_buttons_frame.grid(row=5, column=0, sticky="ew")

        fix_button = ttk.Button(
            action_buttons_frame,
            text="Исправить транзитивность",
            command=self.fix_transitivity
        )
        fix_button.pack(side="left", expand=True, fill="x", padx=5)

        normalize_button = ttk.Button(
            action_buttons_frame,
            text="Нормализовать данные",
            command=self.normalize_data
        )
        normalize_button.pack(side="right", expand=True, fill="x", padx=5)

        # Стиль большой кнопки
        style = ttk.Style()
        style.configure("Big.TButton", font=('TkDefaultFont', 10), padding=(8, 6))

    @staticmethod
    def show_error(title, msg):
        """Единственный способ вывода ошибок."""
        messagebox.showerror(title, f"{msg}\n\nЕсли ошибка повторяется, проверьте размер вашей матрицы.")

    def select_single_file(self, entry_widget, context):
        fp = filedialog.askopenfilename(initialdir=r"Dozor\experts", title="Выбрать файл", filetypes=[("Excel Files", "*.xlsx")])
        if not fp:
            return
        try:
            # Проверяем только выбранный лист
            wb = load_workbook(fp, data_only=True)
            sn = wb.active.title
            matrix = self.controller.load_matrix_from_sheet(wb[sn]) # <— здесь будет проверка на квадратность
        except Exception as e:
            self.show_error("Ошибка открытия файла", str(e))
            return
        entry_widget.config(state='normal')
        entry_widget.delete("1.0", "end")
        entry_widget.insert("end", fp)
        entry_widget.config(state='disabled')

    def select_multiple_files(self, context):
        fps = filedialog.askopenfilenames(initialdir=r"Dozor\experts", title="Выбрать файлы", filetypes=[("Excel Files", "*.xlsx")])
        if not fps:
            return
        valid_fps = []
        sheets = set()

        for fp in fps:
            try:
                # Проверяем ВСЕ листы во всех файлах сразу
                wb = load_workbook(fp, data_only=True)
                for sn in wb.sheetnames:
                    matrix = self.controller.load_matrix_from_sheet(wb[sn])
                    # ❗❗❗ Здесь происходит ваша главная проверка ТЗ: если хоть одна матрица не квадратная — вылетает исключение.
                    if not is_square_matrix(matrix):  
                        raise ValueError(f"Матрица на листе '{sn}' не является квадратной ({len(matrix)} x {len(matrix[0])}).")
                    else:
                        sheets.add(sn)
                valid_fps.append(fp)
            except Exception as e:
                self.show_error("Ошибка загрузки файла", f"{fp}: {str(e)}. Файл будет проигнорирован.")

        # Заполняем комбобокс после проверки
        self.sheet_combobox['values'] = sorted(sheets)
        if len(sheets) > 0:
            self.sheet_combobox.current(0)

        # Выводим список файлов
        self.expert_files_text.config(state='normal')
        self.expert_files_text.delete("1.0", "end")
        self.expert_files_text.insert("end", "\n".join(valid_fps))
        self.expert_files_text.config(state='disabled')

    def reset_transitivity_field(self):
        self.transitivity_label.config(text="")

    def check_transitivity(self):
        selected_sheet = self.sheet_combobox.get()
        if not selected_sheet or not hasattr(self.controller, '_loaded_wbs'):
            self.show_error("Внимание", "Сначала выберите файл и лист!")
            return

        first_fp = getattr(self.controller, 'input_files', [None])[0]
        if not first_fp:
            self.show_error("Внимание", "Сначала выберите файл!")
            return

        # Берём открытую книгу или грузим заново
        wb = self.controller._loaded_wbs.get(first_fp)
        if not wb:
            wb = load_workbook(first_fp, data_only=True)

        ws = wb[selected_sheet]
        consistent, total = self.controller.check_transitivity(ws)
        percentage = int((consistent / total) * 100) if total != 0 else 0
        self.transitivity_label.config(text=f"{percentage}%")

    def fix_transitivity(self):
        file_path = self.target_file_path_entry.get("1.0", "end").strip()
        selected_sheet = self.sheet_combobox.get()
        if not file_path or not selected_sheet:
            self.show_error("Внимание", "Укажите файл и лист!")
            return

        success = self.controller.apply_transitivity_corrections(file_path, selected_sheet)
        if success:
            messagebox.showinfo("Готово", "Транзитивность исправлена и файл сохранён.")

    def normalize_data(self):
        file_path = self.target_file_path_entry.get("1.0", "end").strip()
        if not file_path:
            self.show_error("Внимание", "Выберите целевой файл Excel!")
            return

        # Нормализация по методу Саати (собственный вектор)
        # Этот метод уже реализован ниже, в модуле контроллера.
        success = self.controller.send_to_database(file_path)
        if success:
            messagebox.showinfo("Готово", "Данные нормализованы и добавлены в таблицу.")

    def aggregate_data(self):
        output_filename = self.new_file_name_entry.get().strip() + ".xlsx"
        input_files = self.expert_files_text.get("1.0", "end").splitlines()
        selected_sheet = self.sheet_combobox.get()

        # Проверки
        if not output_filename:
            self.show_error("Предупреждение", "Введите имя файла для сохранения результата.")
            return
        if not input_files or len(input_files) == 0:
            self.show_error("Внимание", "Нужно выбрать хотя бы один экспертный файл.")
            return
        if not selected_sheet:
            self.show_error("Внимание", "Выберите лист (группу) для агрегирования.")
            return

        # ❗❗❗ ВАЖНОЕ ИСПРАВЛЕНИЕ АГРЕГАЦИИ
        # Теперь мы сначала проверяем все матрицы на идентичный размер
        # и только потом запускаем объединение.
        matrices = []
        size = None

        for fp in input_files:
            try:
                wb = load_workbook(fp, read_only=True, data_only=True)
                mat = self.controller.load_matrix_from_sheet(wb[selected_sheet])
                if not size:
                    size = len(mat)
                elif len(mat) != size:
                    raise ValueError("Размер матриц различается между файлами.")
                matrices.append(mat)
            except Exception as e:
                self.show_error("Ошибка агрегации", f"Проблема с файлом {fp}: {str(e)}")
                return

        # Если дошли до сюда — всё хорошо, передаём в контроллер
        workbook = self.controller.aggregate_data(output_filename, matrices, selected_sheet)

        if workbook is None:
            self.show_error("Ошибка данных", "Не удалось собрать данные.")
            return

        project_root = Path(__file__).resolve().parents[2]
        dest_path = project_root / "experts" / output_filename
        save_to_experts(dest_path, workbook)

# ДОПОЛНИТЕЛЬНЫЕ МЕТОДЫ, КОТОРЫХ НЕ БЫЛО В КОНТРОЛЛЕРЕ

def is_square_matrix(matrix: list[list[int]]) -> bool:
    """Возвращает True, если матрица квадратная и содержит числа."""
    n = len(matrix)
    if n == 0:
        return False
    for row in matrix:
        if len(row) != n:
            return False
        for cell in row:
            if not isinstance(cell, (int, float)):
                return False
    return True

def save_to_experts(path: Path, workbook: Workbook) -> None:
    """Сохраняет книгу в папку Experts."""
    os.makedirs(path.parent, exist_ok=True)
    workbook.save(str(path))










# Dozor/modules/excel/excel_aggregate_work.py

import os
from pathlib import Path
from statistics import mean, mode, StatisticsError
from openpyxl import load_workbook, Workbook  # Импортируем только то, что реально используется
from collections import defaultdict
from openpyxl import Workbook


def normalize_cell(cell):
    """Преобразует ячейку к числу или строке."""
    if cell is None or str(cell).strip() == "":
        return ""
    
    val_str = str(cell).replace(',', '.').strip().upper()
    try:
        num = float(val_str)
        # Если число целое (например, 5.0), сохраняем как int
        return int(num) if abs(num - int(num)) < 1e-9 else round(num, 3)
    except ValueError:
        return val_str


class AggregateWorkController:
    def __init__(self):
        self.input_files = []          # Здесь храним РЕАЛЬНЫЕ ПУТИ выбранных экспертных файлов
        self._loaded_wbs = {}         # Кэш открытых книг Excel
        self.target_file_path = ""     # Целевой файл для записи результата

    ### ⚙️ Новые вспомогательные функции ###

    @staticmethod
    def _is_square_matrix(matrix: list[list[int]]) -> bool:
        """
        Проверяет, является ли матрица квадратной и содержит только числа.
        Возвращает True/False без выбрасывания исключения.
        """
        n = len(matrix)
        if not matrix or n != len(matrix[0]):
            return False
        
        for row in matrix:
            if len(row) != n:
                return False
            
            for cell in row:
                # Это критическое требование из ТЗ!
                if not isinstance(cell, (int, float)):
                    return False
        
        return True

    def load_matrix_from_sheet(self, ws):
        """
        Загружает числовую часть матрицы со смещением от I-1 (ячейка D2).
        Выполняет проверку на квадратность.
        Выбрасывает исключение при ошибке.
        """
        rows = ws.iter_rows(min_row=2, min_col=4, values_only=True)
        matrix = [[normalize_cell(c) for c in row[:ws.max_column - 3]] for row in rows]
        matrix = [row for row in matrix if any(row)]

        # ✅ Критическая проверка из ТЗ
        if not self._is_square_matrix(matrix):
            raise ValueError(f"Матрица на листе '{ws.title}' имеет неверный размер ({len(matrix)} x {len(matrix[0])})")
        
        return matrix

    ### Методы контроллера ###

    def aggregate_data(
        self,
        output_filename,   # Без типов, чтобы не было проблем с импортами
        input_matrices,
        selected_sheet_name
    ):
        """
        Объединение информации.
        На вход подаются уже подготовленные матрицы (из метода интерфейса),
        которые прошли предварительную проверку на квадратность.
        """

        # Проверим, что все матрицы имеют одинаковый размер
        sizes = set(len(mat) for mat in input_matrices)
        if len(sizes) > 1:
            messagebox.showerror("Ошибка", "Размер матриц различается между файлами.")
            return

        size = next(iter(sizes))

        # Агрегирование по моде / среднему
        aggregated = []
        for i in range(size):
            new_row = []
            for j in range(i + 1, size):
                vals = [mat[i][j] for mat in input_matrices]
                try:
                    # Мода
                    val = mode(vals)
                except StatisticsError:
                    # Среднее арифметическое
                    numeric_vals = [x for x in vals if isinstance(x, (int, float))]
                    val = round(mean(numeric_vals), 3) if numeric_vals else 1.0

                # Правило из вашего ТЗ: если моду определить нельзя, выставляется единица
                if val <= 0:
                    val = 1.0

                new_row.append(val)
                # Заполнение нижней части симметрично
                new_row.insert(0, round(1 / val, 3))

            # Диагональные элементы всегда равны единице
            new_row.insert(i, 1.0)
            aggregated.append(new_row)

        # Создание книги
        wb = Workbook()
        ws = wb.active
        ws.title = selected_sheet_name

        # Верхняя шапка (I-1, I-2...)
        for col_idx in range(size):
            ws.cell(row=1, column=col_idx + 4, value=f"I-{col_idx+1}")

        # Числовое тело
        for r_idx, row in enumerate(aggregated):
            for c_idx, val in enumerate(row):
                target_col = c_idx + 4
                ws.cell(row=r_idx + 2, column=target_col, value=val)

        # Сохранить книгу
        project_root = Path(__file__).resolve().parents[2]
        experts_folder = project_root / "experts"
        dest_path = experts_folder / output_filename

        os.makedirs(experts_folder, exist_ok=True)
        try:
            wb.save(str(dest_path))
            print("[GUI SAVE SUCCESS]")
            return wb
        except Exception as e:
            print(f"[GUI FATAL ERROR]: {e}")
            messagebox.showerror("Ошибка сохранения", f"Не удалось сохранить файл:\n{e}")
            return None

    def check_transitivity(self, sheet) -> tuple[int, int]:
        """
        Классическая проверка транзитивности A->B && B->C => A->C.
        Возвращает количество согласованных цепочек и общее количество проверок.
        """
        matrix = self.load_matrix_from_sheet(sheet)

        total = consistent = 0
        n = len(matrix)

        for a in range(n):
            for b in range(n):
                ab = matrix[a][b]
                ba = matrix[b][a]
                # Проверяем только строгие отношения (A больше B)
                if ab >= 1 and ba < 1:
                    for c in range(n):
                        bc = matrix[b][c]
                        cb = matrix[c][b]
                        ac = matrix[a][c]
                        ca = matrix[c][a]
                        # Ищем цепочку: A > B > C
                        if bc >= 1 and cb < 1:
                            total += 1
                            # Транзитивно, если A > C
                            if ac >= 1 and ca < 1:
                                consistent += 1

        return consistent, total

    def apply_transitivity_corrections(self, file_path=None, sheet_name=None):
        """
        Исправляет нарушения транзитивности.
        Принцип: нижнетреугольная часть всегда равна обратной величине верхнетреугольной.
        """
        if not file_path or not sheet_name:
            return False

        wb = self._loaded_wbs.get(file_path)
        if not wb:
            try:
                wb = load_workbook(file_path, data_only=True)
                self._loaded_wbs[file_path] = wb
            except Exception as e:
                print(f"[FIX LOAD ERR]: {e}")
                return False

        if sheet_name not in wb.sheetnames:
            return False

        ws = wb[sheet_name]
        matrix = self.load_matrix_from_sheet(ws)

        corrected = False
        n = len(matrix)

        # Проходим только по верхней треугольной части
        for i in range(n):
            for j in range(i + 1, n):
                upper_val = matrix[i][j]
                lower_val = matrix[j][i]

                # Проверка на нарушение свойства обратного сравнения
                if isinstance(upper_val, (int, float)) and isinstance(lower_val, (int, float)):
                    if abs(upper_val * lower_val - 1) > 0.01:
                        # Ставим обратно пропорциональное значение
                        new_lower = round(1 / upper_val, 3)
                        ws.cell(row=j+1, column=i+4, value=new_lower)
                        corrected = True

        # Сохранение
        if corrected:
            try:
                wb.save(file_path)
                print("[FIX SAVED]")
                return True
            except Exception as e:
                print(f"[FIX SAVE ERR]: {e}")
                return False
        else:
            print("[FIX NONE]")
            return True

    ### Новый метод для нормализации (строка №9)
    def send_to_database(self, file_path: str) -> bool:
        """
        Нормализация данных методом Саати (собственный вектор).
        Данные добавляются в правый столбец напротив соответствующих показателей.
        """
        wb = load_workbook(file_path)
        sn = wb.active.title
        ws = wb[sn]

        # Загрузка матрицы
        matrix = self.load_matrix_from_sheet(ws)
        n = len(matrix)

        # Вычисление собственного вектора (геометрические средние строк)
        vector = []
        for row in matrix:
            product = 1.0
            for cell in row:
                # Избегаем деления на ноль
                if cell <= 0:
                    continue
                product *= cell
            geom_mean = pow(product, 1/n)
            vector.append(geom_mean)

        # Нормируем вектор так, чтобы сумма была равна 1
        sum_vector = sum(vector)
        normalized = [v/sum_vector for v in vector]

        # Добавляем данные в таблицу
        max_col = ws.max_column
        for idx, norm_val in enumerate(normalized):
            # Строка начинается с 2 (т.к. первая занята заголовками)
            ws.cell(row=idx+2, column=max_col+1, value=norm_val)

        # Сохраняем изменения
        try:
            wb.save(file_path)
            return True
        except Exception as e:
            print(f"[NORM SAVE ERR]: {e}")
            return False











# Dozor/modules/excel/excel_export_inter.py

import tkinter as tk
from tkinter import ttk, messagebox
from modules.excel.excel_export_work import ExportWorkController
from modules.utils.window_behavior import LevelTwoBehavior
from openpyxl import load_workbook, Workbook  

class ExpertExportInterface(tk.Toplevel):
    def __init__(self, parent):
        super().__init__(parent)
        
        behavior = LevelTwoBehavior()
        behavior.setup_level_two(self.winfo_toplevel())
        
        self.title("Создание файла для эксперта")
        self.geometry("450x200")
        self.resizable(False, False)
        
        self.controller = ExportWorkController()
        main_frame = ttk.Frame(self)
        main_frame.pack(padx=10, pady=10, fill="both", expand=True)
        
        ttk.Label(main_frame, text="Группа:").grid(row=0, column=0, sticky="w")
        self.group_var = tk.StringVar()
        self.combobox = ttk.Combobox(
            main_frame, textvariable=self.group_var, state="readonly", width=25
        )
        self.combobox.grid(row=0, column=1, padx=(5, 10), sticky="ew")
        self.refresh_groups_list()
        
        add_group_btn = ttk.Button(
            main_frame, text="Добавить группу", command=self.add_group
        )
        add_group_btn.grid(row=0, column=2, padx=(0, 10), sticky="e")
        
        ttk.Label(main_frame, text="Выбранные группы:").grid(row=1, column=0, sticky="w")
        self.selected_groups_text = tk.Text(main_frame, width=30, height=4)
        self.selected_groups_text.grid(
            row=1, column=1, columnspan=2, pady=(5, 10), sticky="ew"
        )
        
        ttk.Label(main_frame, text="Количество экспертов:").grid(
            row=2, column=0, sticky="w"
        )
        self.num_experts = tk.IntVar(value=1)
        self.experts_count_entry = ttk.Entry(main_frame, textvariable=self.num_experts, width=5)
        self.experts_count_entry.grid(row=2, column=1, sticky="w")
        
        remove_group_btn = ttk.Button(
            main_frame, text="Удалить группу", command=self.remove_group
        )
        remove_group_btn.grid(row=2, column=2, sticky="e")
        
        export_btn = ttk.Button(
            main_frame, text="Экспортировать в Excel", command=self.export_to_excel
        )
        export_btn.grid(row=3, column=1, pady=(10, 0))

    def refresh_groups_list(self):
        self.controller.refresh_groups()
        self.combobox["values"] = self.controller.groups
        if self.controller.groups:
            self.combobox.current(0)

    def add_group(self):
        group_name = self.group_var.get()
        self.controller.add_group(group_name)
        self._update_selected_text()

    def remove_group(self):
        self.controller.remove_group()
        self._update_selected_text()

    def _update_selected_text(self):
        current_text = "\n".join(self.controller.selected_groups)
        self.selected_groups_text.delete("1.0", tk.END)
        self.selected_groups_text.insert(tk.END, current_text)

    def export_to_excel(self):
        selected_group_names = (
            self.selected_groups_text.get("1.0", tk.END).strip().split("\n")
        )
        try:
            num_experts = int(self.num_experts.get())
            if num_experts <= 0:
                raise ValueError
        except Exception:
            messagebox.showerror(
                "Ошибка", "Количество экспертов должно быть положительным числом."
            )
            return
            
        success = self.controller.export_to_excel(selected_group_names, num_experts)
        if success:
            messagebox.showinfo(
                title="Экспорт завершён",
                message=f"Успешно создано {num_experts} файлов.\n\nРасположение:\n.../experts/",
            )












# Dozor/modules/excel/excel_export_work.py

import os
from pathlib import Path
from openpyxl import Workbook
from openpyxl import load_workbook, Workbook

try:
    from modules.data.database_module import get_db_connection
except ImportError:
    print("[EXPORT WORK] Не найден database_module.")
    
    def get_db_connection():
        return None

def create_excel_template(groups_objects, output_filename):
    wb = Workbook()
    
    if "Sheet" in wb.sheetnames:
        del wb["Sheet"]
        
    created_any_sheet = False
    
    for group_data in groups_objects:
        indicators_list = group_data.get('indicators', [])
        if not indicators_list:
            continue
            
        sheet_title = f"{group_data['name']}"
        if len(sheet_title) > 31:
            sheet_title = sheet_title[:31]
            
        ws = wb.create_sheet(title=sheet_title)
        created_any_sheet = True
        
        # --- ЗАГОЛОВКИ ЛЕВОЙ ЧАСТИ ---
        headers = ["№ п/п", "Наименование показателя", "Код"]
        for col_idx, header in enumerate(headers, start=1):
            ws.cell(row=1, column=col_idx, value=header)
        
        # --- ДАННЫЕ ЛЕВОЙ ЧАСТИ ---
        for idx, ind_tuple in enumerate(indicators_list, start=1):
            name, code = ind_tuple[0], ind_tuple[1]
            ws.cell(row=idx + 1, column=1, value=str(idx))
            ws.cell(row=idx + 1, column=2, value=name)
            ws.cell(row=idx + 1, column=3, value=code)
        
        # --- ШАПКА МАТРИЦЫ (ВЕРХ) ---
        matrix_start_col = 4
        matrix_start_row = 1
        
        for col_offset, ind_tuple in enumerate(indicators_list):
            code = ind_tuple[1]
            ws.cell(row=matrix_start_row, column=matrix_start_col + col_offset, value=code)
            
        # --- ТЕЛО МАТРИЦЫ ---
        size = len(indicators_list)
        
        for r in range(size):
            for c in range(size):
                cell = ws.cell(
                    row=matrix_start_row + r + 1,
                    column=matrix_start_col + c
                )
                
                if r == c:
                    cell.value = 1.0
                else:
                    cell.value = ""
                    
    if not created_any_sheet:
        ws = wb.create_sheet(title="Пусто")
        ws['A1'] = 'Нет индикаторов.'
        
    try:
        project_root = Path(__file__).resolve().parents[2]
        experts_folder = project_root / "experts"
        os.makedirs(experts_folder, exist_ok=True)
        full_path = experts_folder / output_filename
        wb.save(full_path)
    except Exception as e:
        print(f"[EXPORT WORK ERROR] {e}")

class ExportWorkController:
    def __init__(self):
        self.groups = []
        self.selected_groups = []

    def refresh_groups(self):
        """Читает названия групп из БД."""
        conn = get_db_connection()
        if not conn:
            self.groups = ["Ошибка БД"]
            return
            
        cursor = conn.cursor()
        cursor.execute("SELECT id, name FROM groups ORDER BY name")
        rows = cursor.fetchall()
        
        self.groups = [row[1] for row in rows]
        # Сохраняем ID групп для экспорта
        self._gid_cache = {row[1]: row[0] for row in rows}
        
        conn.close()

    # === НЕДОСТАЮЩИЕ МЕТОДЫ ДЛЯ ИНТЕРФЕЙСА ===
    def add_group(self, group_name):
        """Добавляет группу в список выбранных (вызывается при нажатии кнопки)."""
        if group_name and group_name.strip() and group_name not in self.selected_groups:
            self.selected_groups.append(group_name)

    def remove_group(self):
        """Удаляет последнюю добавленную группу (кнопка Удалить)."""
        if len(self.selected_groups) > 0:
            self.selected_groups.pop(-1)

    def export_to_excel(self, selected_group_names, num_experts):
        if int(num_experts) <= 0 or not selected_group_names:
            return False

        conn = get_db_connection()
        if not conn:
            return False
            
        cursor = conn.cursor()
        
        enriched_groups = []
        for gname in selected_group_names:
            gid = self._gid_cache.get(gname)
            if not gid:
                continue
                
            cursor.execute("""
                SELECT name, code 
                FROM indicators 
                WHERE group_code IN (
                    SELECT code FROM groups WHERE id = ?
                ) 
                ORDER BY name
            """, (gid,))
            
            indicators = cursor.fetchall()
            
            enriched_groups.append({
                'name': gname,
                'indicators': indicators
            })
            
        conn.close()

        if not enriched_groups:
            return False

        project_root = Path(__file__).resolve().parents[2]
        experts_folder = project_root / "experts"
        os.makedirs(experts_folder, exist_ok=True)
        
        success = True
        for expert_num in range(1, int(num_experts) + 1):
            filename = f"Эксперт_{expert_num}.xlsx"
            full_path = experts_folder / filename
            
            create_excel_template(enriched_groups, str(filename))
            
            if not full_path.exists():
                success = False
                
        return success











# Dozor/modules/excel/excel_ui.py

import tkinter as tk
import openpyxl 
from tkinter import ttk
from pathlib import Path
from modules.utils.window_behavior import LevelTwoBehavior
from modules.excel.excel_export_inter import ExpertExportInterface
from modules.excel.excel_aggregate_inter import ExpertAggregateInterface

class ExpertsFacade(tk.Toplevel):
    def __init__(self, master, current_user):
        super().__init__(master)
        
        self.title("Dozor - Эксперты")
        # Фиксируем минимальный размер, чтобы окно не схлопнулось при centering
        self.minsize(400, 300) 
        self.resizable(False, False)
        
        self.current_user = current_user
        
        # --- СЕТКА ОКНА ---
        self.grid_rowconfigure(0, weight=1) # Хедер
        self.grid_rowconfigure(1, weight=5) # Тело (растягивается)
        self.grid_columnconfigure(0, weight=1)
        
        self._create_header()
        self._create_body()
        
        # Даем Tkinter время рассчитать размеры виджетов ДО перемещения окна
        self.update_idletasks()
        
        # Применяем поведение уровня 2 ПОСЛЕ создания контента
        behavior = LevelTwoBehavior()
        behavior.setup_level_two(self)

    def _create_header(self):
        header = tk.Frame(self, bg="#34495e", height=50)
        header.grid(row=0, column=0, sticky="ew")
        
        title = tk.Label(header, text="Раздел Эксперта", bg="#34495e", fg="white", font=("Arial", 16))
        title.pack(side="left", padx=20, pady=10)
        
        back_btn = tk.Button(header, text="← Назад", command=self.destroy, bg="#7f8c8d", fg="white", relief="flat", bd=0, activebackground="#95a5a6")
        back_btn.pack(side="right", padx=20, pady=10)

    def _create_body(self):
        body = tk.Frame(self, bg="white") 
        body.grid(row=1, column=0, sticky="nsew", padx=30, pady=(30, 30)) # Увеличили нижний отступ для баланса
        
        # Настройка сетки: пустое пространство сверху -> кнопка 1 -> кнопка 2 -> пустое снизу
        body.grid_rowconfigure(0, weight=2)   # Пустота сверху (тянется сильнее)
        body.grid_rowconfigure(1, weight=1)   # Кнопка экспорта
        body.grid_rowconfigure(2, weight=1)   # Кнопка агрегирования
        body.grid_rowconfigure(3, weight=2)   # Пустота снизу (тянется сильнее)
        body.grid_columnconfigure(0, weight=1)
        
        export_btn = tk.Button(
            body, text="Создание Excel файлов", font=("Arial", 14), width=25, height=2,
            command=self.open_export_window,
            bg="#3498db", fg="white", relief="raised", bd=2, activebackground="#5dade2"
        )
        export_btn.grid(row=1, column=0, pady=5, sticky="ew")
        
        aggregate_btn = tk.Button(
            body, text="Агрегирование Excel файлов", font=("Arial", 14), width=25, height=2,
            command=self.open_aggregate_window,
            bg="#27ae60", fg="white", relief="raised", bd=2, activebackground="#52be80"
        )
        aggregate_btn.grid(row=2, column=0, pady=5, sticky="ew")

    def open_export_window(self):
        win = ExpertExportInterface(self)
        self.wait_window(win)

    def open_aggregate_window(self):
        win = ExpertAggregateInterface(self)
        self.wait_window(win)