Загрузка данных
# 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)