/
Paff
/
declarant
Обзор
Документация
Войти
/
Paff
/
declarant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
gui.py
3 022 строки
136 KB
1pash1985-gif
feat(gui): add 'О программе' button with git version display
21 июл 2026, 21:27
21 июл 2026, 21:27
e69799e
Код
Авторство
О чём код?
""" GUI интерфейс для программы обработки инвойсов. Сессионная архитектура с SQLite хранением истории. """ import os import subprocess import threading import copy from tkinter import * from tkinter import ttk, filedialog, messagebox, simpledialog, colorchooser from datetime import datetime from tksheet import Sheet # Excel через COM (Windows only) try: from excel_widget import ExcelWidget, WIN32_AVAILABLE except ImportError: WIN32_AVAILABLE = False from models import InvoiceData, InvoiceItem from flower_db import FlowerDatabase, extract_client_from_folder from pdf_parser import InvoiceParser, FolderScanner, save_invoices_to_excel, parse_invoices_from_excel from excel_writer import ExcelTableManager, ResultAggregator from database import DatabaseManager from ocr_parser import is_ocr_available, preload_ocr_models, get_ocr_version, get_downloaded_models, ensure_ocr_models from version import get_version class ClosableNotebook(ttk.Notebook): """Notebook с закрываемыми вкладками (кнопка X) и кнопкой добавления (+).""" def __init__(self, master, on_tab_close=None, on_new_tab=None, **kwargs): super().__init__(master, **kwargs) self._on_tab_close = on_tab_close self._on_new_tab = on_new_tab self._pinned_tabs = set() # Вкладки которые нельзя закрыть self._close_buttons = {} # {tab_id: Label} - кнопки закрытия # Правый клик для контекстного меню self.bind("<Button-3>", self._on_right_click) # Ctrl+W для закрытия активной вкладки self.bind("<Control-w>", lambda e: self._close_active_tab()) self.bind("<Control-W>", lambda e: self._close_active_tab()) # Стили self.style = ttk.Style() # Обновлять позиции кнопок при изменении размера self.bind("<Configure>", self._update_close_buttons_position) self.bind("<<NotebookTabChanged>>", self._update_close_buttons_position) def add_closable_tab(self, frame, text, pinned=False, insert_before=None, **kwargs): """Добавить вкладку с кнопкой закрытия.""" display_text = f" {text} " if pinned else f" {text} " # Пробел для места кнопки if insert_before is not None: self.insert(insert_before, frame, text=display_text, **kwargs) else: self.add(frame, text=display_text, **kwargs) tab_id = self.tabs()[-1] if insert_before is None else self.tabs()[self.index(insert_before) - 1] if pinned: self._pinned_tabs.add(tab_id) else: # Создать кнопку закрытия для этой вкладки self._create_close_button(tab_id) self._update_close_buttons_position() return tab_id def _create_close_button(self, tab_id): """Создать кнопку закрытия для вкладки.""" if tab_id in self._close_buttons: return # Создать Label как кнопку закрытия btn = Label(self, text="×", fg="gray", bg="lightgray", font=("Arial", 10, "bold"), cursor="hand2", width=2, height=1, relief="flat") btn.bind("<Button-1>", lambda e, tid=tab_id: self._on_close_button_click(e, tid)) btn.bind("<Enter>", lambda e, b=btn: b.config(fg="red", bg="lightcoral")) btn.bind("<Leave>", lambda e, b=btn: b.config(fg="gray", bg="lightgray")) self._close_buttons[tab_id] = btn btn.place(relx=1.0, rely=0.0, anchor="ne", x=-5, y=5) # Временно, позиция обновится def _on_close_button_click(self, event, tab_id): """Обработка клика на кнопку закрытия.""" if tab_id in self._pinned_tabs: return self._close_tab(tab_id) return "break" def _update_close_buttons_position(self, event=None): """Обновить позиции всех кнопок закрытия.""" try: self.update_idletasks() for tab_id, btn in list(self._close_buttons.items()): try: # Проверить, существует ли ещё вкладка if tab_id not in self.tabs(): btn.destroy() del self._close_buttons[tab_id] continue # Получить индекс вкладки idx = self.index(tab_id) bbox = self.bbox(idx) if bbox and bbox[2] > 0: # bbox работает — позиционировать кнопку bx, by, bw, bh = bbox btn.place(x=bx + bw - 20, y=by + bh // 2 - 8, anchor="center") btn.lift() else: # bbox не работает — скрыть кнопку btn.place_forget() except Exception: btn.place_forget() except Exception: pass def pin_tab(self, tab_id): """Закрепить вкладку (нельзя закрыть).""" self._pinned_tabs.add(tab_id) # Убрать кнопку закрытия if tab_id in self._close_buttons: self._close_buttons[tab_id].destroy() del self._close_buttons[tab_id] # Убрать пробелы из текста current_text = self.tab(tab_id, "text") if current_text.endswith(" "): self.tab(tab_id, text=current_text[:-2]) def _on_click(self, event): """Обработка клика на вкладку — ничего не делаем, Tkinter сам переключает.""" pass def _close_active_tab(self): """Закрыть текущую активную вкладку (Ctrl+W).""" try: current = self.select() if current in self._pinned_tabs: return self._close_tab(current) except Exception: pass def _on_right_click(self, event): """Обработка правого клика на вкладку — контекстное меню.""" try: self.update_idletasks() # Получить текущую выбранную вкладку current = self.select() if not current or current in self._pinned_tabs: return # Показать контекстное меню menu = Menu(self, tearoff=0) menu.add_command(label="Закрыть вкладку", command=lambda: self._close_tab(current)) menu.post(event.x_root, event.y_root) return "break" except Exception: pass def _close_tab(self, tab_id): """Закрыть вкладку.""" if tab_id in self._pinned_tabs: return if self._on_tab_close: if not self._on_tab_close(tab_id): return # Удалить кнопку закрытия if tab_id in self._close_buttons: self._close_buttons[tab_id].destroy() del self._close_buttons[tab_id] try: self.forget(tab_id) except Exception: pass def get_tab_text(self, tab_id): """Получить чистый текст вкладки.""" text = self.tab(tab_id, "text") return text.strip() class DeclarantApp: """Главное окно приложения.""" def __init__(self, root: Tk): self.root = root self.root.title("Декларант - Обработка инвойсов") self.root.geometry("1200x800") self.root.minsize(1000, 700) # Инициализация компонентов self.flower_db = FlowerDatabase() self.parser = InvoiceParser(self.flower_db) self.scanner = FolderScanner(self.flower_db) self.excel_manager = ExcelTableManager() self.aggregator = ResultAggregator() # База данных (SQLite) self.db = DatabaseManager() self.db.initialize() # Состояние приложения self.base_config = None # Конфигурация базовой таблицы self.current_session_id = None # ID текущей сессии self.current_session_name = "" self.invoice_folder = "" # Данные для таблицы (цветок x клиент) self.scan_results = {} self.aggregated_results = [] self.table_data = {} # {flower: {client: boxes}} self.table_clients = [] self.table_flowers = [] self.flower_full_data = {} # {flower_en: {name_en, name_ru, nl, code, bg_color}} self.flower_list = [] # Упорядоченный список строк (включая дубликаты) # Переменные для редактирования ячеек self._edit_entry = None self._edit_item = None self._edit_col_idx = None self._edit_col_name = None # Выбранные PDF файлы (для режима загрузки файлов) self._selected_pdf_files = [] # === Управление вкладками таблиц === self._table_tabs = {} # {tab_id: TabData} - данные каждой вкладки self._active_tab_id = None # ID активной вкладки self._tab_counter = 0 # Счётчик для уникальных имён вкладок # Undo/Redo стек для Excel-like функциональности self._undo_stack = [] # Список предыдущих состояний self._redo_stack = [] # Список для redo self._max_undo = 50 # Максимум записей в undo # Создание интерфейса self._create_ui() # Проверка первого запуска self.root.after(100, self._check_first_launch) def _create_menu_bar(self): """Создать верхнее меню (Menu Bar).""" 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._new_session) file_menu.add_command(label="Открыть сессию", command=self._open_session) file_menu.add_command(label="Сохранить сессию", command=self._save_session) file_menu.add_command(label="История", command=self._show_history) file_menu.add_separator() file_menu.add_command(label="Выход", command=self.root.quit) # --- Инвойсы --- inv_menu = Menu(menubar, tearoff=0) menubar.add_cascade(label="Инвойсы", menu=inv_menu) inv_menu.add_command(label="Загрузить инвойсы", command=self._load_invoices) inv_menu.add_command(label="Сканировать", command=self._start_scan) inv_menu.add_command(label="Очистить", command=self._clear_session) # --- Excel --- excel_menu = Menu(menubar, tearoff=0) menubar.add_cascade(label="Excel", menu=excel_menu) excel_menu.add_command(label="Экспорт в Excel", command=self._export_to_excel) excel_menu.add_command(label="Открыть в Excel", command=self._open_in_excel) excel_menu.add_command(label="Загрузить из Excel", command=self._load_from_excel) excel_menu.add_separator() excel_menu.add_command(label="Записать в базу", command=self._write_to_base) # --- Инструменты --- tools_menu = Menu(menubar, tearoff=0) menubar.add_cascade(label="Инструменты", menu=tools_menu) tools_menu.add_command(label="На почту", command=self._send_email) tools_menu.add_command(label="OCR модели (F9)", command=self._show_ocr_dialog) tools_menu.add_command(label="+ Цветок", command=self._add_flower_dialog) def _create_ui(self): """Создать пользовательский интерфейс.""" # === Menu Bar === self._create_menu_bar() # Главная панель main_frame = ttk.Frame(self.root, padding="10") main_frame.grid(row=0, column=0, sticky="nsew") self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) main_frame.columnconfigure(0, weight=1) main_frame.rowconfigure(2, weight=1) # === Панель сессии (компактная) === session_frame = ttk.Frame(main_frame) session_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5)) session_frame.columnconfigure(1, weight=1) self.session_var = StringVar(value="(нет активной сессии)") ttk.Label(session_frame, text="Сессия:", foreground="gray").pack(side="left", padx=(0, 5)) ttk.Label(session_frame, textvariable=self.session_var, font=("Arial", 9, "bold")).pack(side="left", padx=(0, 15)) self.folder_var = StringVar(value="") ttk.Label(session_frame, textvariable=self.folder_var, foreground="gray").pack(side="left") # Скрытая переменная для базовой таблицы (используется внутри) self.base_var = StringVar(value="(не загружена)") # Горячая клавиша для OCR диалога self.root.bind("<F9>", lambda event: self._show_ocr_dialog()) # === Статус бар === status_frame = ttk.Frame(main_frame) status_frame.grid(row=1, column=0, sticky="ew", pady=(0, 5)) status_frame.columnconfigure(0, weight=1) self.status_var = StringVar(value="Готов к работе") ttk.Label(status_frame, textvariable=self.status_var).grid(row=0, column=0, sticky="w") self.progress_var = DoubleVar(value=0) self.progress_bar = ttk.Progressbar(status_frame, variable=self.progress_var, maximum=100, length=200) self.progress_bar.grid(row=0, column=1, sticky="e") # === Notebook с вкладками === self.notebook = ClosableNotebook(main_frame, on_tab_close=self._on_tab_close_request) self.notebook.grid(row=2, column=0, sticky="nsew") self.notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed) # --- Вкладка 1: Список результатов (закреплённая) --- list_frame = ttk.Frame(self.notebook, padding="5") self.notebook.add_closable_tab(list_frame, text="Список распознавания", pinned=True) list_frame.columnconfigure(0, weight=1) list_frame.rowconfigure(0, weight=1) columns = ("client", "flower", "boxes", "stems", "supplier", "file", "status") self.tree = ttk.Treeview(list_frame, columns=columns, show="headings", selectmode="extended") self.tree.heading("client", text="Клиент") self.tree.heading("flower", text="Цветок") self.tree.heading("boxes", text="Коробки") self.tree.heading("stems", text="Стебли") self.tree.heading("supplier", text="Поставщик") self.tree.heading("file", text="Файл") self.tree.heading("status", text="Статус") self.tree.column("client", width=120, anchor="w") self.tree.column("flower", width=250, anchor="w") self.tree.column("boxes", width=80, anchor="center") self.tree.column("stems", width=80, anchor="center") self.tree.column("supplier", width=150, anchor="w") self.tree.column("file", width=200, anchor="w") self.tree.column("status", width=100, anchor="w") vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview) hsb = ttk.Scrollbar(list_frame, orient="horizontal", command=self.tree.xview) self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) self.tree.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") hsb.grid(row=1, column=0, sticky="ew") # --- Кнопка Очистить внизу --- btn_frame = ttk.Frame(list_frame) btn_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(5, 0)) ttk.Button(btn_frame, text="Очистить", command=self._clear_recognition_list).pack(side="right") # --- Вкладка "Таблица европа" больше не создаётся по умолчанию --- # Пользователь может создать её вручную через кнопку "Открыть в Excel" # --- Кнопка добавления новой вкладки (+) --- new_tab_frame = ttk.Frame(self.notebook, width=30, height=25) self.notebook.add(new_tab_frame, text=" + ") self.notebook._pinned_tabs.add(self.notebook.tabs()[-1]) # Закрепить кнопку + self.notebook.bind("<<NotebookTabChanged>>", self._on_tab_or_new_clicked) # Контекстное меню для списка распознавания self.context_menu = Menu(self.root, tearoff=0) self.context_menu.add_command(label="Сопоставить цветок...", command=self._match_flower_manual) self.context_menu.add_command(label="Изменить клиента...", command=self._edit_client) self.context_menu.add_separator() self.context_menu.add_command(label="Открыть файл инвойса", command=self._open_invoice_file) self.context_menu.add_separator() self.context_menu.add_command(label="Удалить запись", command=self._delete_selected) self.tree.bind("<Button-3>", self._show_context_menu) self.tree.bind("<Double-1>", self._on_double_click) # === Горячие клавиши для Excel-like функций === self.root.bind("<Control-z>", lambda e: self._undo_action()) self.root.bind("<Control-y>", lambda e: self._redo_action()) self.root.bind("<Control-v>", lambda e: self._paste_from_clipboard()) self.root.bind("<Delete>", lambda e: self._delete_selected_cells()) # === Нижняя панель === info_frame = ttk.Frame(main_frame) info_frame.grid(row=3, column=0, sticky="ew", pady=(5, 0)) flowers_count = len(self.flower_db.get_all_flowers()) self.info_var = StringVar(value=f"Цветов в базе: {flowers_count}") ttk.Label(info_frame, textvariable=self.info_var, foreground="gray").pack(side="left") # Кнопки для работы с таблицами if WIN32_AVAILABLE: ttk.Button(info_frame, text="Открыть Excel", command=self._open_excel_in_tab).pack(side="right", padx=5) ttk.Button(info_frame, text="Новая таблица", command=self._create_new_table_tab).pack(side="right", padx=5) ttk.Button(info_frame, text="Загрузить таблицу", command=self._load_table_as_new_tab).pack(side="right", padx=5) ttk.Button(info_frame, text="О программе", command=self._show_about_dialog).pack(side="right", padx=5) # === Управление вкладками таблиц === def _create_table_tab(self, tab_name, is_default=False, table_data=None): """ Создать новую вкладку с таблицей. Args: tab_name: Имя вкладки is_default: Если True — это стандартная вкладка "Таблица европа" table_data: Опциональный dict с данными таблицы """ table_frame = ttk.Frame(self.notebook, padding="5") table_frame.columnconfigure(0, weight=1) table_frame.rowconfigure(1, weight=1) # Панель поиска и инструментов toolbar_frame = ttk.Frame(table_frame) toolbar_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5)) search_frame = ttk.Frame(toolbar_frame) search_frame.pack(side="left") ttk.Label(search_frame, text="Поиск:").pack(side="left", padx=(0, 5)) search_var = StringVar() search_entry = ttk.Entry(search_frame, textvariable=search_var, width=25) search_entry.pack(side="left", padx=(0, 5)) ttk.Button(search_frame, text="Найти", command=lambda: self._search_in_tab(tab_id)).pack(side="left", padx=(0, 5)) ttk.Button(search_frame, text="Сбросить", command=lambda: self._reset_search_in_tab(tab_id)).pack(side="left") # Кнопки Excel-like функций excel_frame = ttk.Frame(toolbar_frame) excel_frame.pack(side="right") ttk.Button(excel_frame, text="↩ Undo", command=self._undo_action, width=7).pack(side="left", padx=2) ttk.Button(excel_frame, text="↪ Redo", command=self._redo_action, width=7).pack(side="left", padx=2) ttk.Button(excel_frame, text="📋 Вставить", command=self._paste_from_clipboard, width=9).pack(side="left", padx=2) # Контейнер для tksheet table_container = ttk.Frame(table_frame) table_container.grid(row=1, column=0, sticky="nsew") table_container.columnconfigure(0, weight=1) table_container.rowconfigure(0, weight=1) # tksheet sheet = Sheet(table_container, show_x_scrollbar=True, show_y_scrollbar=True) sheet.grid(row=0, column=0, sticky="nsew") # Excel-like bindings sheet.enable_bindings( "single_select", "drag_select", "column_width_resize", "row_height_resize", "arrowkeys", "copy", "select_all", "paste", "cut", "undo", "edit_cell" ) sheet.set_options( show_horizontal_grid=True, show_vertical_grid=True, paste_insert_column_if_not_exist=True, paste_insert_row_if_not_exist=True, undo=True ) # Info label info_label = ttk.Label(table_frame, text="Таблица будет заполнена после сканирования", foreground="gray") info_label.grid(row=2, column=0, sticky="w", pady=(5, 0)) # Вставить вкладку перед кнопкой "+" (если она есть) all_tabs = self.notebook.tabs() insert_before_tab = None if len(all_tabs) > 0: # Проверить, является ли последняя вкладка кнопкой "+" last_tab = all_tabs[-1] try: last_text = self.notebook.tab(last_tab, 'text').strip() if last_text == '+': insert_before_tab = last_tab except: pass # Использовать add_closable_tab для создания вкладки с кнопкой закрытия tab_id = self.notebook.add_closable_tab(table_frame, text=tab_name, pinned=False, insert_before=insert_before_tab) # Сохранить данные вкладки tab_data = { "name": tab_name, "frame": table_frame, "sheet": sheet, "search_var": search_var, "search_entry": search_entry, "info_label": info_label, "container": table_container, # Данные таблицы для этой вкладки "table_data": table_data.get("table_data", {}) if table_data else {}, "table_clients": table_data.get("table_clients", []) if table_data else [], "table_flowers": table_data.get("table_flowers", []) if table_data else [], "flower_list": table_data.get("flower_list", []) if table_data else [], "flower_full_data": table_data.get("flower_full_data", {}) if table_data else {}, # Undo/Redo для каждой вкладки "undo_stack": [], "redo_stack": [], "is_default": is_default, } self._table_tabs[tab_id] = tab_data # Обработчики событий sheet.bind("<Double-Button-1>", lambda e, tid=tab_id: self._on_sheet_double_click_tab(e, tid)) sheet.bind("<<SheetModified>>", lambda e, tid=tab_id: self._on_sheet_modified(tid)) # Контекстное меню для таблицы tab_context = Menu(self.root, tearoff=0) tab_context.add_command(label="Редактировать ячейку", command=lambda tid=tab_id: self._edit_cell_in_tab(tid)) tab_context.add_command(label="Вставить из буфера", command=lambda tid=tab_id: self._paste_in_tab(tid)) tab_context.add_separator() tab_context.add_command(label="Добавить строку", command=lambda tid=tab_id: self._add_row_in_tab(tid)) tab_context.add_command(label="Удалить строку", command=lambda tid=tab_id: self._delete_row_in_tab(tid)) tab_context.add_separator() tab_context.add_command(label="Открыть в Excel", command=lambda tid=tab_id: self._open_tab_in_excel(tid)) tab_context.add_command(label="Загрузить из Excel", command=lambda tid=tab_id: self._load_excel_to_tab(tid)) tab_context.add_separator() tab_context.add_command(label="Переименовать вкладку", command=lambda tid=tab_id: self._rename_tab(tid)) sheet.bind("<Button-3>", lambda e, m=tab_context: self._show_tab_context_menu(e, m)) tab_data["context_menu"] = tab_context # Если это вкладка по умолчанию, сохранить ссылки для обратной совместимости if is_default: self.table_tree = sheet self.table_container = table_container self.table_info_label = info_label self.search_var = search_var self.search_entry = search_entry # Переключиться на новую вкладку self.notebook.select(table_frame) self._active_tab_id = tab_id return tab_id def _get_active_table_tab(self): """Получить данные активной вкладки таблицы.""" if not self._active_tab_id or self._active_tab_id not in self._table_tabs: # Попробовать найти текущую активную вкладку try: current = self.notebook.select() if current in self._table_tabs: self._active_tab_id = current return self._table_tabs[current] except: pass return None return self._table_tabs.get(self._active_tab_id) def _on_tab_changed(self, event): """Обработка переключения вкладок.""" try: current = self.notebook.select() if current in self._table_tabs: self._active_tab_id = current tab = self._table_tabs[current] # Обновить обратную совместимость self.table_tree = tab["sheet"] self.table_container = tab["container"] self.table_info_label = tab["info_label"] self.search_var = tab["search_var"] self.search_entry = tab["search_entry"] # Загрузить данные из вкладки в глобальные переменные self.table_data = tab["table_data"] self.table_clients = tab["table_clients"] self.table_flowers = tab["table_flowers"] self.flower_list = tab["flower_list"] self.flower_full_data = tab["flower_full_data"] except: pass def _on_tab_or_new_clicked(self, event): """Обработка: если нажали на '+' вкладку — создать новую.""" try: current = self.notebook.select() # Проверить, является ли текущая вкладка кнопкой "+" all_tabs = self.notebook.tabs() if all_tabs and current == all_tabs[-1]: # Это вкладка "+", переключиться обратно и создать новую if len(all_tabs) > 1: self.notebook.select(all_tabs[-2]) self._create_new_table_tab() else: self._on_tab_changed(event) except: pass def _on_tab_close_request(self, tab_id): """Запрос на закрытие вкладки.""" if tab_id not in self._table_tabs: return True tab = self._table_tabs[tab_id] # Для Excel вкладок — спросить о сохранении if tab.get("type") == "excel": result = messagebox.askyesnocancel( "Закрыть Excel", f"Закрыть вкладку '{tab['name']}'?\n\n" "ДА — сохранить и закрыть\n" "НЕТ — закрыть без сохранения\n" "ОТМЕНА — не закрывать" ) if result is None: # Отмена return False if result: # Сохранить excel_widget = tab.get("excel_widget") if excel_widget: excel_widget.save() # Закрыть Excel excel_widget = tab.get("excel_widget") if excel_widget: excel_widget.close() del self._table_tabs[tab_id] return True # Для обычных вкладок if tab.get("is_default"): result = messagebox.askyesno( "Закрыть вкладку", f"Закрыть вкладку '{tab['name']}'?\n\nДанные вкладки будут потеряны." ) else: result = messagebox.askyesno( "Закрыть вкладку", f"Закрыть вкладку '{tab['name']}'?\n\nДанные вкладки будут потеряны." ) if result: # Удалить данные вкладки del self._table_tabs[tab_id] return True return False def _create_new_table_tab(self): """Создать новую пустую вкладку таблицы.""" self._tab_counter += 1 tab_name = f"Таблица {self._tab_counter}" # Запросить имя у пользователя name = simpledialog.askstring( "Новая таблица", "Имя новой таблицы:", initialvalue=tab_name, parent=self.root ) if not name: self._tab_counter -= 1 return # Создать полностью пустую вкладку (без данных из base_config) tab_id = self._create_table_tab(name) self._update_status(f"Создана новая вкладка: {name}") def _load_table_as_new_tab(self): """Загрузить таблицу из файла как новую вкладку.""" file = filedialog.askopenfilename( title="Выберите таблицу для загрузки", filetypes=[ ("Excel файлы", "*.xlsm *.xlsx *.xls"), ("XLSM файлы", "*.xlsm"), ("XLSX файлы", "*.xlsx"), ("XLS файлы", "*.xls"), ("Все файлы", "*.*") ] ) if not file: return tab_name = os.path.splitext(os.path.basename(file))[0] # Попробовать загрузить через ExcelTableManager temp_manager = ExcelTableManager() if temp_manager.load_table(file): flowers = temp_manager.get_flowers_list() clients = temp_manager.get_clients_list() flower_list = temp_manager.get_flowers_ordered_list() flower_full_data = temp_manager.get_flowers_full_data() tab_data = { "table_data": {}, "table_clients": clients, "table_flowers": [entry["name_en"] for entry in flower_list] if flower_list else flowers, "flower_list": flower_list, "flower_full_data": flower_full_data, } tab_id = self._create_table_tab(tab_name, table_data=tab_data) self._sync_tab_to_globals(tab_id) self._build_visual_table_for_tab(tab_id) self._update_status(f"Загружена таблица: {tab_name} ({len(flower_list)} строк, {len(clients)} клиентов)") messagebox.showinfo("Успех", f"Таблица загружена!\n\nСтрок: {len(flower_list)}\nКлиентов: {len(clients)}") else: # Если не удалось через ExcelTableManager, создать пустую вкладку tab_id = self._create_table_tab(tab_name) messagebox.showwarning("Внимание", f"Не удалось распарсить файл '{os.path.basename(file)}'.\nСоздана пустая вкладка.") def _open_excel_in_tab(self): """Открыть Excel файл в новой вкладке с полным Excel функционалом.""" if not WIN32_AVAILABLE: messagebox.showwarning("Недоступно", "Excel интеграция недоступна.\n\nУстановите pywin32:\npip install pywin32") return # Выбрать Excel файл file = filedialog.askopenfilename( title="Выберите Excel файл", filetypes=[ ("Excel файлы", "*.xlsx *.xlsm *.xls"), ("XLSX файлы", "*.xlsx"), ("XLSM файлы", "*.xlsm"), ("XLS файлы", "*.xls"), ("Все файлы", "*.*") ] ) if not file: return tab_name = os.path.splitext(os.path.basename(file))[0] # Создать вкладку с Excel self._create_excel_tab(tab_name, file) def _create_excel_tab(self, tab_name, file_path): """Создать вкладку с встроенным Excel.""" if not WIN32_AVAILABLE: return None # Создать Frame для вкладки excel_frame = ttk.Frame(self.notebook) excel_frame.columnconfigure(0, weight=1) excel_frame.rowconfigure(0, weight=1) # Панель инструментов toolbar = ttk.Frame(excel_frame) toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 5)) ttk.Label(toolbar, text=f"Excel: {os.path.basename(file_path)}").pack(side="left", padx=5) # Вставить вкладку перед кнопкой "+" (если она есть) all_tabs = self.notebook.tabs() insert_before_tab = None if len(all_tabs) > 0: last_tab = all_tabs[-1] try: last_text = self.notebook.tab(last_tab, 'text').strip() if last_text == '+': insert_before_tab = last_tab except: pass # Добавить вкладку if insert_before_tab: self.notebook.insert(insert_before_tab, excel_frame, text=f" {tab_name} [Excel] ") else: self.notebook.add(excel_frame, text=f" {tab_name} [Excel] ") # Определить tab_id tab_id = None for tid in self.notebook.tabs(): try: widget = self.notebook.nametowidget(tid) if widget == excel_frame: tab_id = tid break except: pass if tab_id is None: tab_id = self.notebook.tabs()[-1] if not insert_before_tab else self.notebook.tabs()[self.notebook.index(insert_before_tab) - 1] # Создать ExcelWidget excel_widget = ExcelWidget(excel_frame, file_path=file_path) excel_widget.grid(row=1, column=0, sticky="nsew") # Кнопки уже внутри ExcelWidget, не дублируем # Сохранить данные вкладки self._table_tabs[tab_id] = { "name": tab_name, "frame": excel_frame, "excel_widget": excel_widget, "file_path": file_path, "type": "excel", # Отметить как Excel вкладку } # Переключиться на новую вкладку self.notebook.select(excel_frame) self._active_tab_id = tab_id self._update_status(f"Открыт Excel файл: {os.path.basename(file_path)}") return tab_id def _sync_tab_to_globals(self, tab_id): """Синхронизировать данные вкладки с глобальными переменными.""" if tab_id not in self._table_tabs: return tab = self._table_tabs[tab_id] self.table_data = tab["table_data"] self.table_clients = tab["table_clients"] self.table_flowers = tab["table_flowers"] self.flower_list = tab["flower_list"] self.flower_full_data = tab["flower_full_data"] self.table_tree = tab["sheet"] self.table_container = tab["container"] self.table_info_label = tab["info_label"] self.search_var = tab["search_var"] self.search_entry = tab["search_entry"] def _sync_globals_to_tab(self, tab_id=None): """Сохранить глобальные данные обратно во вкладку.""" tid = tab_id or self._active_tab_id if tid and tid in self._table_tabs: tab = self._table_tabs[tid] tab["table_data"] = self.table_data tab["table_clients"] = self.table_clients tab["table_flowers"] = self.table_flowers tab["flower_list"] = self.flower_list tab["flower_full_data"] = self.flower_full_data def _build_visual_table_for_tab(self, tab_id): """Построить визуальную таблицу для конкретной вкладки.""" if tab_id not in self._table_tabs: return # Сохранить текущие глобальные данные old_data = (self.table_data, self.table_clients, self.table_flowers, self.flower_list, self.flower_full_data, self.table_tree, self.table_container, self.table_info_label) # Переключить на данные вкладки self._sync_tab_to_globals(tab_id) # Построить таблицу self._build_visual_table() # Сохранить обратно self._sync_globals_to_tab(tab_id) # Восстановить глобальные данные (self.table_data, self.table_clients, self.table_flowers, self.flower_list, self.flower_full_data, self.table_tree, self.table_container, self.table_info_label) = old_data def _on_sheet_double_click_tab(self, event, tab_id): """Обработка двойного клика в конкретной вкладке.""" self._sync_tab_to_globals(tab_id) self._on_sheet_double_click(event) self._sync_globals_to_tab(tab_id) def _on_sheet_modified(self, tab_id): """Обработка изменения данных в таблице (для undo).""" pass def _search_in_tab(self, tab_id): """Поиск в конкретной вкладке.""" self._sync_tab_to_globals(tab_id) self._search_flower() def _reset_search_in_tab(self, tab_id): """Сброс поиска в конкретной вкладке.""" self._sync_tab_to_globals(tab_id) self._reset_search() def _edit_cell_in_tab(self, tab_id): """Редактировать ячейку в конкретной вкладке.""" self._sync_tab_to_globals(tab_id) tab = self._table_tabs.get(tab_id) if tab: sheet = tab["sheet"] sel = sheet.get_selected() if sel: # Получить выделенную ячейку try: row, col = sel[0] # Эмулировать двойной клик self._on_sheet_double_click_tab(type('Event', (), {'x': 0, 'y': 0})(), tab_id) except: pass def _paste_in_tab(self, tab_id): """Вставить из буфера в конкретную вкладку.""" if tab_id in self._table_tabs: self._table_tabs[tab_id]["sheet"].paste() def _add_row_in_tab(self, tab_id): """Добавить строку в конкретной вкладке.""" if tab_id in self._table_tabs: sheet = self._table_tabs[tab_id]["sheet"] sheet.insert_rows() def _delete_row_in_tab(self, tab_id): """Удалить строку в конкретной вкладке.""" if tab_id in self._table_tabs: sheet = self._table_tabs[tab_id]["sheet"] try: sheet.delete_rows() except: pass def _open_tab_in_excel(self, tab_id): """Открыть данные вкладки в Excel.""" self._sync_tab_to_globals(tab_id) self._open_in_excel() def _load_excel_to_tab(self, tab_id): """Загрузить данные из Excel в конкретную вкладку.""" self._sync_tab_to_globals(tab_id) self._load_from_excel() self._sync_globals_to_tab(tab_id) def _rename_tab(self, tab_id): """Переименовать вкладку.""" if tab_id not in self._table_tabs: return tab = self._table_tabs[tab_id] new_name = simpledialog.askstring( "Переименовать вкладку", "Новое имя:", initialvalue=tab["name"], parent=self.root ) if new_name: tab["name"] = new_name self.notebook.tab(tab_id, text=f" {new_name} × ") def _show_tab_context_menu(self, event, menu): """Показать контекстное меню таблицы.""" try: menu.tk_popup(event.x_root, event.y_root) finally: menu.grab_release() # === Excel-like функции === def _undo_action(self): """Undo действие для активной таблицы.""" tab = self._get_active_table_tab() if tab and tab.get("undo_stack"): state = tab["undo_stack"].pop() tab["redo_stack"].append(copy.deepcopy(tab["table_data"])) tab["table_data"] = state self._sync_tab_to_globals(self._active_tab_id) self._build_visual_table() self._update_status("Undo выполнен") def _redo_action(self): """Redo действие для активной таблицы.""" tab = self._get_active_table_tab() if tab and tab.get("redo_stack"): state = tab["redo_stack"].pop() tab["undo_stack"].append(copy.deepcopy(tab["table_data"])) tab["table_data"] = state self._sync_tab_to_globals(self._active_tab_id) self._build_visual_table() self._update_status("Redo выполнен") def _paste_from_clipboard(self): """Вставить данные из буфера обмена в активную таблицу.""" tab = self._get_active_table_tab() if tab: try: tab["sheet"].paste() self._update_status("Данные вставлены из буфера") except Exception as e: self._update_status(f"Ошибка вставки: {e}") def _delete_selected_cells(self): """Удалить (очистить) выбранные ячейки.""" tab = self._get_active_table_tab() if tab: sheet = tab["sheet"] try: selected = sheet.get_selected() if selected: # Сохранить состояние для undo tab["undo_stack"].append(copy.deepcopy(tab["table_data"])) if len(tab["undo_stack"]) > self._max_undo: tab["undo_stack"].pop(0) tab["redo_stack"].clear() for cell in selected: row, col = cell try: sheet.set_cell_data(row, col, "") except: pass self._update_status("Ячейки очищены") except Exception as e: self._update_status(f"Ошибка удаления: {e}") # === Работа с базовой таблицей === def _check_first_launch(self): """Проверить первый запуск и загрузить базовую конфигурацию.""" if self.db.has_base_config(): self.base_config = self.db.get_base_config() self.base_var.set(os.path.basename(self.base_config["xlsm_path"])) # Загрузить расширенные данные и упорядоченный список из Excel xlsm_path = self.base_config["xlsm_path"] if os.path.exists(xlsm_path): if self.excel_manager.load_table(xlsm_path): self.flower_full_data = self.excel_manager.get_flowers_full_data() self.flower_list = self.excel_manager.get_flowers_ordered_list() # Использовать упорядоченный список для таблицы self.table_flowers = [entry["name_en"] for entry in self.flower_list] if self.flower_list else self.base_config['flowers'] self.table_clients = self.base_config['clients'] self.table_data = {} # Синхронизировать с вкладкой по умолчанию self._sync_globals_to_tab() self._build_visual_table() else: messagebox.showinfo("Первый запуск", "Добро пожаловать!\n\nПожалуйста, загрузите базовую таблицу XLSM.") self._load_base_table() # Предзагрузить OCR модели в фоне (если OCR доступен) self.root.after(2000, self._preload_ocr_with_dialog) def _preload_ocr_with_dialog(self): """Показать диалог загрузки OCR моделей и инициализировать EasyOCR.""" if not is_ocr_available(): return # Создать окно загрузки dialog = Toplevel(self.root) dialog.title("Подготовка OCR") dialog.geometry("450x180") dialog.resizable(False, False) dialog.transient(self.root) dialog.grab_set() # Центрировать окно dialog.update_idletasks() x = (dialog.winfo_screenwidth() - dialog.winfo_width()) // 2 y = (dialog.winfo_screenheight() - dialog.winfo_height()) // 2 dialog.geometry(f"+{x}+{y}") ttk.Label(dialog, text="Идёт подготовка OCR для распознавания сканов...", font=("Arial", 10)).pack(pady=(15, 5)) ttk.Label(dialog, text="Первый запуск может занять 2-5 минут.", foreground="gray").pack() status_var = StringVar(value="Загрузка моделей...") status_label = ttk.Label(dialog, textvariable=status_var, foreground="blue") status_label.pack(pady=(10, 5)) progress_var = DoubleVar(value=0) progress = ttk.Progressbar(dialog, variable=progress_var, mode="determinate", maximum=100, length=400) progress.pack(padx=20, pady=(0, 15)) def update_status(msg: str): self.root.after(0, lambda: status_var.set(msg)) def update_progress(value: int): self.root.after(0, lambda: progress_var.set(value)) def do_preload(): try: preload_ocr_models(status_callback=update_status, progress_callback=update_progress) except Exception as e: self.root.after(0, lambda: status_var.set(f"Ошибка OCR: {e}")) finally: self.root.after(0, dialog.destroy) # Запустить загрузку в фоновом потоке threading.Thread(target=do_preload, daemon=True).start() def _show_ocr_dialog(self): """Показать диалог с информацией об OCR и управлением моделями.""" try: dialog = Toplevel(self.root) dialog.title("OCR - Распознавание текста") dialog.geometry("450x320") dialog.resizable(False, False) dialog.transient(self.root) dialog.grab_set() # Центрировать окно dialog.update_idletasks() x = (dialog.winfo_screenwidth() - dialog.winfo_width()) // 2 y = (dialog.winfo_screenheight() - dialog.winfo_height()) // 2 dialog.geometry(f"+{x}+{y}") # Версия OCR version = get_ocr_version() ttk.Label(dialog, text="OCR движок: EasyOCR", font=("Arial", 11, "bold")).pack(pady=(15, 5)) ttk.Label(dialog, text=f"Версия: {version}", foreground="gray").pack() # Статус доступности available = is_ocr_available() status_text = "Установлен" if available else "Не установлен" status_color = "green" if available else "red" ttk.Label(dialog, text=f"Статус: {status_text}", foreground=status_color).pack(pady=(5, 10)) # Список скачанных моделей ttk.Label(dialog, text="Скачанные модели:", font=("Arial", 10, "bold")).pack(anchor="w", padx=20, pady=(10, 5)) models_frame = ttk.Frame(dialog) models_frame.pack(fill="both", expand=True, padx=20, pady=(0, 10)) models_listbox = Listbox(models_frame, height=6) models_listbox.pack(side="left", fill="both", expand=True) models_scroll = ttk.Scrollbar(models_frame, orient="vertical", command=models_listbox.yview) models_scroll.pack(side="right", fill="y") models_listbox.configure(yscrollcommand=models_scroll.set) models = get_downloaded_models() if available else [] if models: for model in models: models_listbox.insert("end", model) else: models_listbox.insert("end", "(модели не найдены)") # Кнопки действий button_frame = ttk.Frame(dialog) button_frame.pack(fill="x", padx=20, pady=(5, 15)) def refresh_models(): models_listbox.delete(0, "end") new_models = get_downloaded_models() if new_models: for model in new_models: models_listbox.insert("end", model) else: models_listbox.insert("end", "(модели не найдены)") def download_models(): dialog.destroy() self._download_ocr_models_dialog() ttk.Button(button_frame, text="Обновить список", command=refresh_models).pack(side="left", padx=(0, 5)) ttk.Button(button_frame, text="Скачать модели (ru + en)", command=download_models, state="normal" if available else "disabled").pack(side="left", padx=(0, 5)) except Exception as e: messagebox.showerror("Ошибка OCR диалога", str(e)) def _download_ocr_models_dialog(self): """Показать диалог загрузки OCR моделей с прогрессом.""" if not is_ocr_available(): messagebox.showerror("Ошибка", "EasyOCR не установлен.") return progress_dialog = Toplevel(self.root) progress_dialog.title("Загрузка OCR моделей") progress_dialog.geometry("450x180") progress_dialog.resizable(False, False) progress_dialog.transient(self.root) progress_dialog.grab_set() progress_dialog.update_idletasks() x = (progress_dialog.winfo_screenwidth() - progress_dialog.winfo_width()) // 2 y = (progress_dialog.winfo_screenheight() - progress_dialog.winfo_height()) // 2 progress_dialog.geometry(f"+{x}+{y}") ttk.Label(progress_dialog, text="Скачивание моделей EasyOCR...", font=("Arial", 10)).pack(pady=(15, 5)) ttk.Label(progress_dialog, text="Это может занять несколько минут.", foreground="gray").pack() status_var = StringVar(value="Подготовка...") ttk.Label(progress_dialog, textvariable=status_var, foreground="blue").pack(pady=(10, 5)) progress_var = DoubleVar(value=0) progress_bar = ttk.Progressbar(progress_dialog, variable=progress_var, mode="determinate", maximum=100, length=400) progress_bar.pack(padx=20, pady=(0, 15)) def update_status(msg: str): self.root.after(0, lambda: status_var.set(msg)) def update_progress(value: int): self.root.after(0, lambda: progress_var.set(value)) def do_download(): try: ensure_ocr_models( languages=("ru", "en"), status_callback=update_status, progress_callback=update_progress ) except Exception as e: self.root.after(0, lambda: status_var.set(f"Ошибка: {e}")) finally: self.root.after(2000, progress_dialog.destroy) threading.Thread(target=do_download, daemon=True).start() def _load_base_table(self): """Загрузить базовую таблицу XLSM.""" file = filedialog.askopenfilename( title="Выберите базовую таблицу XLSM", filetypes=[("Excel файлы", "*.xlsm *.xlsx"), ("Все файлы", "*.*")] ) if not file: return if self.excel_manager.load_table(file): flowers = self.excel_manager.get_flowers_list() clients = self.excel_manager.get_clients_list() # Получить расширенные данные и упорядоченный список self.flower_full_data = self.excel_manager.get_flowers_full_data() self.flower_list = self.excel_manager.get_flowers_ordered_list() self.db.save_base_config(file, flowers, clients) self.base_config = self.db.get_base_config() self.base_var.set(os.path.basename(file)) # Использовать упорядоченный список (с дубликатами) self.table_flowers = [entry["name_en"] for entry in self.flower_list] self.table_clients = clients self.table_data = {} # Синхронизировать с активной вкладкой (по умолчанию "Таблица европа") self._sync_globals_to_tab() self._build_visual_table() # Переключиться на вкладку таблицы (индекс 1) try: tabs = self.notebook.tabs() if len(tabs) > 1: self.notebook.select(tabs[1]) except: pass # Обновить данные в активной вкладке после построения self._sync_globals_to_tab() messagebox.showinfo("Успех", f"Базовая таблица сохранена!\n\nСтрок: {len(self.flower_list)}\nКлиентов: {len(clients)}") else: messagebox.showerror("Ошибка", "Не удалось загрузить таблицу!") # === Работа с сессиями === def _new_session(self): """Создать новую сессию.""" if not self.base_config: messagebox.showwarning("Внимание", "Сначала загрузите базовую таблицу!") return # Запросить имя сессии name = simpledialog.askstring( "Новая сессия", "Введите имя сессии:", parent=self.root ) if not name: return # Выбрать режим загрузки: папка или файлы choice = messagebox.askyesnocancel( "Загрузить инвойсы", "Выберите тип загрузки:\\n\\n" "ДА — Выбрать папку (все инвойсы внутри, включая подпапки)\\n" "НЕТ — Выбрать конкретные файлы\\n" "Отмена — Выход" ) if choice is None: return if choice: # Выбрать папку с инвойсами folder = filedialog.askdirectory(title="Выберите папку с инвойсами") if not folder: return selected_files = None else: # Выбрать конкретные файлы files = filedialog.askopenfilenames( title="Выберите инвойсы (Ctrl+A — все в папке)", filetypes=[ ("Все инвойсы", "*.pdf;*.xls;*.xlsx;*.doc"), ("PDF файлы", "*.pdf"), ("Excel файлы", "*.xls;*.xlsx"), ("Word файлы", "*.doc"), ("Все файлы", "*.*") ] ) if not files: return selected_files = list(files) folder = os.path.dirname(selected_files[0]) if len(selected_files) == 1 else os.path.commonpath(selected_files) # Создать сессию в БД self.current_session_id = self.db.create_session(name, folder) self.current_session_name = name self.invoice_folder = folder # Обновить UI self.session_var.set(name) self.folder_var.set(folder) # Очистить данные предыдущей сессии (НО сохранить базовую структуру из XLSM) self.table_data = {} # Данные сессии очищаем self.aggregated_results = [] # Результаты сканирования очищаем # НЕ очищаем table_flowers и table_clients — они из базовой таблицы # Восстановить базовую структуру из base_config if self.base_config: self.table_flowers = [entry["name_en"] for entry in self.flower_list] if self.flower_list else self.base_config['flowers'] self.table_clients = list(self.base_config['clients']) # Копия базовых клиентов for item in self.tree.get_children(): self.tree.delete(item) self.table_tree.clear() self._update_status(f"Создана сессия: {name}") # Автоматически запустить сканирование if selected_files: self._selected_pdf_files = selected_files self._start_scan_files() else: self._start_scan() def _open_session(self): """Открыть существующую сессию.""" sessions = self.db.get_all_sessions() if not sessions: messagebox.showinfo("Информация", "Нет сохраненных сессий.") return # Диалог выбора сессии dialog = Toplevel(self.root) dialog.title("Открыть сессию") dialog.geometry("600x400") dialog.transient(self.root) dialog.grab_set() ttk.Label(dialog, text="Выберите сессию:").pack(anchor="w", padx=20, pady=(15, 5)) # Список сессий columns = ("name", "date", "status", "boxes") tree = ttk.Treeview(dialog, columns=columns, show="headings", height=15) tree.heading("name", text="Имя") tree.heading("date", text="Дата") tree.heading("status", text="Статус") tree.heading("boxes", text="Коробок") tree.column("name", width=200) tree.column("date", width=150) tree.column("status", width=100) tree.column("boxes", width=80, anchor="center") for s in sessions: date_str = datetime.fromisoformat(s["date"]).strftime("%d.%m.%Y %H:%M") tree.insert("", "end", iid=str(s["id"]), values=(s["name"], date_str, s["status"], s["total_boxes"])) tree.pack(fill="both", expand=True, padx=20, pady=10) def open_selected(): sel = tree.selection() if not sel: messagebox.showwarning("Внимание", "Выберите сессию!", parent=dialog) return session_id = int(sel[0]) self._load_session(session_id) dialog.destroy() ttk.Button(dialog, text="Открыть", command=open_selected).pack(pady=10) def _load_session(self, session_id: int): """Загрузить сессию из БД.""" session = self.db.get_session(session_id) if not session: messagebox.showerror("Ошибка", "Сессия не найдена!") return self.current_session_id = session_id self.current_session_name = session["name"] self.invoice_folder = session["invoice_folder"] or "" # Обновить UI self.session_var.set(session["name"]) self.folder_var.set(self.invoice_folder) # Загрузить данные сессии session_data = self.db.get_session_data(session_id) self.table_data, self.table_flowers, self.table_clients = self._session_data_to_table_data(session_data) # Перестроить aggregated_results для списка self.aggregated_results = [] for entry in session_data: self.aggregated_results.append({ "client": entry["client_name"], "flower": entry["flower_name"], "boxes": entry["boxes_count"], "source": self.invoice_folder, "is_matched": True }) # Обновить UI self._update_list_ui() self._build_visual_table() # Активировать кнопки (меню всегда активно) self._update_status(f"Загружена сессия: {session['name']} ({session['total_boxes']} коробок)") def _save_session(self, silent: bool = False): """Сохранить текущую сессию. Args: silent: Если True, не показывать диалог успеха (для автосохранения) """ if not self.current_session_id: if not silent: messagebox.showwarning("Внимание", "Нет активной сессии!") return # Собрать данные из table_data session_data = self._table_data_to_session_data() # Сохранить в БД self.db.save_session_data(self.current_session_id, session_data) # Обновить total_boxes total = sum(entry["boxes_count"] for entry in session_data) self.db.update_session_total_boxes(self.current_session_id, total) self.db.update_session_status(self.current_session_id, "saved") self._update_status(f"Сессия сохранена: {self.current_session_name} ({total} коробок)") if not silent: messagebox.showinfo("Успех", f"Сессия сохранена!\nВсего коробок: {total}") def _clear_session(self): """Очистить данные текущей сессии.""" if not self.current_session_id: return if not messagebox.askyesno("Подтверждение", "Очистить все данные текущей сессии?"): return # Очистить данные (но сохранить структуру таблицы) self.table_data = {} self.aggregated_results = [] # Восстановить базовую структуру (цветов и клиентов) if self.flower_list: self.table_flowers = [entry["name_en"] for entry in self.flower_list] elif self.base_config: self.table_flowers = self.base_config['flowers'] if self.base_config: self.table_clients = self.base_config['clients'] # Очистить БД self.db.clear_session_data(self.current_session_id) self.db.update_session_total_boxes(self.current_session_id, 0) # Очистить UI for item in self.tree.get_children(): self.tree.delete(item) self.table_tree.clear() self.table_info_label.config(text="Сессия очищена") self._update_status("Сессия очищена") def _show_history(self): """Показать историю всех сессий.""" sessions = self.db.get_all_sessions() dialog = Toplevel(self.root) dialog.title("История сессий") dialog.geometry("700x450") dialog.transient(self.root) ttk.Label(dialog, text=f"Всего сессий: {len(sessions)}", font=("Arial", 10, "bold")).pack(anchor="w", padx=20, pady=(15, 5)) columns = ("name", "date", "status", "boxes", "folder") tree = ttk.Treeview(dialog, columns=columns, show="headings", height=15) tree.heading("name", text="Имя сессии") tree.heading("date", text="Дата создания") tree.heading("status", text="Статус") tree.heading("boxes", text="Коробок") tree.heading("folder", text="Папка инвойсов") tree.column("name", width=150) tree.column("date", width=140) tree.column("status", width=80) tree.column("boxes", width=70, anchor="center") tree.column("folder", width=200) for s in sessions: date_str = datetime.fromisoformat(s["date"]).strftime("%d.%m.%Y %H:%M") folder_short = os.path.basename(s["invoice_folder"]) if s["invoice_folder"] else "" tree.insert("", "end", iid=str(s["id"]), values=(s["name"], date_str, s["status"], s["total_boxes"], folder_short)) tree.pack(fill="both", expand=True, padx=20, pady=10) def delete_selected(): sel = tree.selection() if not sel: return if messagebox.askyesno("Подтверждение", "Удалить выбранные сессии?", parent=dialog): for item_id in sel: self.db.delete_session(int(item_id)) tree.delete(item_id) btn_frame = ttk.Frame(dialog) btn_frame.pack(pady=10) ttk.Button(btn_frame, text="Удалить выбранные", command=delete_selected).pack(side="left", padx=5) ttk.Button(btn_frame, text="Закрыть", command=dialog.destroy).pack(side="left", padx=5) # === Сканирование инвойсов === def _load_invoices(self): """Загрузить инвойсы (работает без сессии).""" # Сразу открываем диалог выбора файлов (всё видно) files = filedialog.askopenfilenames( title="Выберите инвойсы (Ctrl+A — все в папке)", filetypes=[ ("Все инвойсы", "*.pdf;*.xls;*.xlsx;*.doc"), ("PDF файлы", "*.pdf"), ("Excel файлы", "*.xls;*.xlsx"), ("Word файлы", "*.doc"), ("Все файлы", "*.*") ] ) if not files: return # Сгруппировать файлы по родительским папкам folder_groups = {} for f in files: parent = os.path.dirname(f) if parent not in folder_groups: folder_groups[parent] = [] folder_groups[parent].append(f) self._selected_pdf_files = list(files) if len(folder_groups) == 1: folder = list(folder_groups.keys())[0] else: folder = os.path.commonpath(list(folder_groups.keys())) self.invoice_folder = folder self.folder_var.set(f"{len(files)} файлов: {os.path.basename(folder)}") self._start_scan_files() def _start_scan_files(self): """Запуск сканирования выбранных файлов.""" if not hasattr(self, '_selected_pdf_files') or not self._selected_pdf_files: return self._update_status(f"Сканирование {len(self._selected_pdf_files)} файлов...") self.progress_var.set(0) for item in self.tree.get_children(): self.tree.delete(item) thread = threading.Thread(target=self._scan_files_thread) thread.daemon = True thread.start() def _scan_files_thread(self): """Поток сканирования выбранных файлов.""" try: results = {} for pdf_path in self._selected_pdf_files: folder_name = os.path.basename(os.path.dirname(pdf_path)) client_name = extract_client_from_folder(folder_name) invoice = self.scanner.parser.parse_file(pdf_path, folder_name) if client_name not in results: results[client_name] = [] results[client_name].append(invoice) self.scan_results = results new_aggregated = self.aggregator.aggregate_by_client_flower(self.scan_results) # Добавляем новые результаты к существующим (не перезаписываем) self.aggregated_results.extend(new_aggregated) self.root.after(0, self._update_results_ui) except Exception as e: self.root.after(0, lambda: messagebox.showerror("Ошибка", f"Ошибка сканирования:\n{e}")) finally: self.root.after(0, lambda: self._update_status("Готово")) def _start_scan(self): """Запуск сканирования инвойсов.""" if not self.invoice_folder: messagebox.showwarning("Внимание", "Выберите папку с инвойсами!") return self._update_status("Сканирование...") self.progress_var.set(0) # Очистить UI for item in self.tree.get_children(): self.tree.delete(item) thread = threading.Thread(target=self._scan_thread) thread.daemon = True thread.start() def _scan_thread(self): """Поток сканирования инвойсов.""" try: self.scan_results = self.scanner.scan_directory(self.invoice_folder) # Собрать все инвойсы в один список all_invoices = [] for client_name, invoices in self.scan_results.items(): all_invoices.extend(invoices) print(f"[DEBUG] Отсканировано инвойсов: {len(all_invoices)}") # Сохранить в Excel для просмотра if all_invoices and WIN32_AVAILABLE: # Создать временный файл для Excel import tempfile temp_dir = tempfile.gettempdir() excel_path = os.path.join( temp_dir, f"Распознанные_инвойсы_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" ) if save_invoices_to_excel(all_invoices, excel_path): self._temp_excel_path = excel_path self.root.after(0, lambda: self._open_scanned_excel(excel_path)) # Агрегировать результаты new_aggregated = self.aggregator.aggregate_by_client_flower(self.scan_results) # Добавляем новые результаты к существующим (не перезаписываем) self.aggregated_results.extend(new_aggregated) self.root.after(0, self._update_results_ui) except Exception as e: self.root.after(0, lambda: messagebox.showerror("Ошибка", f"Ошибка сканирования:\n{e}")) finally: self.root.after(0, lambda: self._update_status("Готово")) def _open_scanned_excel(self, excel_path: str): """Открыть Excel файл с распознанными инвойсами для просмотра.""" if WIN32_AVAILABLE and os.path.exists(excel_path): tab_name = f"Распознанные инвойсы" self._create_excel_tab(tab_name, excel_path) self._update_status(f"Распознанные инвойсы открыты в Excel для просмотра") # Показать сообщение messagebox.showinfo( "Инвойсы распознаны", f"Распознанные инвойсы открыты в Excel.\n\n" f"Вы можете:\n" f"• Просмотреть данные\n" f"• Исправить ошибки распознавания\n" f"• Закрыть Excel когда закончите\n\n" f"Данные уже агрегированы и готовы к экспорту." ) def _update_results_ui(self): """Обновить UI с результатами сканирования.""" self._update_list_ui() # Проверить есть ли результаты if not self.aggregated_results: self.table_info_label.config(text="Распознанных цветов нет. Проверьте формат инвойсов.") self._update_status("Сканирование завершено: данные не найдены") messagebox.showwarning( "Нет данных", "Не удалось распознать цветы в инвойсах.\n\n" "Возможные причины:\n" "• PDF файлы не содержат таблиц с цветами\n" "• Формат инвойсов не поддерживается\n" "• Инвойсы в формате XLS/XLSX (пока не поддерживается)" ) return # Построить table_data из aggregated_results (ДОБАВЛЯЕМ к существующим данным) # НЕ очищаем self.table_data — это позволяет накапливать данные при нескольких загрузках for res in self.aggregated_results: client = res.get("client", "") flower = res.get("flower", "") boxes = res.get("boxes", 0) # Ищем соответствие в базовой таблице flower_upper = flower.upper() matched_flower = None # Ищем точное совпадение if flower_upper in self.flower_full_data: matched_flower = self.flower_full_data[flower_upper]["name_en"] else: # Ищем частичное совпадение for base_flower in self.base_config['flowers']: if flower_upper in base_flower.upper() or base_flower.upper() in flower_upper: matched_flower = base_flower break if matched_flower: flower_key = matched_flower.upper() else: flower_key = flower_upper if flower_key not in self.table_data: self.table_data[flower_key] = {} if client not in self.table_data[flower_key]: self.table_data[flower_key][client] = 0 self.table_data[flower_key][client] += boxes # НЕ перезаписываем table_flowers и table_clients — используем базовую структуру # Только добавляем новых клиентов, если их нет в базовой таблице new_clients = set() for res in self.aggregated_results: client = res.get("client", "") if client and client.upper() not in [c.upper() for c in self.table_clients]: new_clients.add(client) if new_clients: self.table_clients = sorted(set(self.table_clients) | new_clients) self._build_visual_table() # Переключиться на вкладку "Таблица" (индекс 1) try: tabs = self.notebook.tabs() if len(tabs) > 1: self.notebook.select(tabs[1]) except: pass # Синхронизировать данные с вкладкой self._sync_globals_to_tab() # Активировать кнопки (меню всегда активно) if self.aggregated_results: # Автосохранение после сканирования (без диалога) self._save_session(silent=True) # Статус total_boxes = sum(r.get("boxes", 0) for r in self.aggregated_results) self._update_status( f"Сканирование завершено: {len(self.aggregated_results)} позиций, " f"{total_boxes} коробок | Клиентов: {len(self.table_clients)}, " f"Цветов: {len(self.table_flowers)}" ) def _clear_recognition_list(self): """Очистить список распознавания (без привязки к сессии).""" if not messagebox.askyesno("Подтверждение", "Очистить весь список распознавания?"): return self.aggregated_results = [] self.scan_results = {} for item in self.tree.get_children(): self.tree.delete(item) self.progress_var.set(0) self._update_status("Список очищен") def _update_list_ui(self): """Обновить список результатов.""" for item in self.tree.get_children(): self.tree.delete(item) matched_count = 0 unmatched_count = 0 for idx, res in enumerate(self.aggregated_results): client = res.get("client", "") flower = res.get("flower", "") boxes = res.get("boxes", 0) stems = res.get("stems", 0) source = res.get("source", "") file_paths = res.get("file_paths", []) is_matched = res.get("is_matched", False) supplier = os.path.basename(source) if source else "" # Показать имя первого файла из списка file_name = os.path.basename(file_paths[0]) if file_paths else "" status = "Распознан" if is_matched else "Неизвестный" if is_matched: matched_count += 1 else: unmatched_count += 1 self.tree.insert("", "end", iid=str(idx), values=(client, flower, boxes, stems, supplier, file_name, status)) total = len(self.aggregated_results) self._update_status(f"Готово! Позиций: {total}. Распознано: {matched_count}, Неизвестных: {unmatched_count}") self.progress_var.set(100) # === Визуальная таблица === def _build_visual_table(self): """Построить визуальную таблицу 'Таблица европа' идентичную оригинальному XLSM.""" # Проверить, существует ли table_tree (вкладка "Таблица европа") if not hasattr(self, 'table_tree') or self.table_tree is None: return # Вкладка не создана, пропустить построение # Очистить tksheet self.table_tree.clear() if not self.flower_list and not self.table_flowers: self.table_info_label.config(text="Нет данных. Загрузите базовую таблицу.") return if not self.table_clients: self.table_info_label.config(text="Нет клиентов. Загрузите базовую таблицу.") return # Заголовки: Цветок (EN) | Цветок (RU) | NL | Код | К-ВО | Клиенты... headers = ["Цветок (EN)", "Цветок (RU)", "NL", "Код", "К-ВО"] + list(self.table_clients) # Подготовить данные data = [] grand_total = 0 # Заполнить строки из упорядоченного списка (с дубликатами) for entry in self.flower_list: name_en = entry.get("name_en", "") name_ru = entry.get("name_ru", "") nl_value = entry.get("nl", "") code = entry.get("code", "") bg_color = entry.get("bg_color", "#FFFFFF") flower_upper = name_en.upper() row = [name_en, name_ru, nl_value, code] row_total = 0 for client in self.table_clients: boxes = self.table_data.get(flower_upper, {}).get(client, 0) row_total += boxes row.append(str(row_total) if row_total > 0 else "") # К-ВО for client in self.table_clients: boxes = self.table_data.get(flower_upper, {}).get(client, 0) row.append(str(boxes) if boxes > 0 else "") grand_total += row_total data.append(row) # Индекс строки для окраски (без учета итогов) row_idx = len(data) - 1 # Проверить пользовательский цвет в базе user_color = None for f in self.flower_db.get_all_flowers(): if f.full_name_en.upper() == flower_upper: user_color = f.bg_color break if user_color: # Пользовательский цвет — применить ко всей строке text_color = self._get_contrast_text_color(user_color) try: total_cols = 5 + len(self.table_clients) for c in range(total_cols): self.table_tree.highlight_cells(row=row_idx, column=c, bg=user_color, fg=text_color) except Exception: pass elif bg_color and bg_color.upper() not in ["#FFFFFF", "00000000", "FFFFFFFF"]: # Цвет из XLSM — применить только к первым 4 колонкам try: for c in range(4): self.table_tree.highlight_cells(row=row_idx, column=c, bg=bg_color) except Exception: pass # Строка ИТОГО (сумма коробок по каждому клиенту) total_row = ["ИТОГО", "", "", "", str(grand_total)] for client in self.table_clients: client_total = sum(self.table_data.get(f.upper(), {}).get(client, 0) for f in self.table_flowers) total_row.append(str(client_total) if client_total > 0 else "") data.append(total_row) # Строка "Клиентов" (количество уникальных цветов у каждого клиента) clients_row = ["Клиентов", "", "", "", ""] for client in self.table_clients: flowers_count = sum(1 for f in self.table_flowers if self.table_data.get(f.upper(), {}).get(client, 0) > 0) clients_row.append(str(flowers_count) if flowers_count > 0 else "") data.append(clients_row) # Установить данные self.table_tree.set_sheet_data(data) self.table_tree.headers(headers) # Установить ширины колонок col_widths = [250, 200, 40, 90, 60] col_widths += [60] * len(self.table_clients) try: self.table_tree.set_column_widths(col_widths) except Exception: pass # Окрасить строку ИТОГО total_row_idx = len(data) - 2 for c in range(len(headers)): try: self.table_tree.highlight_cells(row=total_row_idx, column=c, bg="#ffc107") except Exception: pass # Окрасить строку Клиентов clients_row_idx = len(data) - 1 for c in range(len(headers)): try: self.table_tree.highlight_cells(row=clients_row_idx, column=c, bg="#e3f2fd") except Exception: pass # Запретить редактирование первых колонок (названия, NL, код) и служебных строк self.table_tree.readonly_columns(columns=[0, 1, 2, 3, 4]) self.table_tree.readonly_rows(rows=[total_row_idx, clients_row_idx]) self.table_info_label.config( text=f"Строк: {len(self.flower_list)} | Клиентов: {len(self.table_clients)} | Всего коробок: {grand_total}" ) # Обработчик редактирования ячеек self.table_tree.bind("<Double-Button-1>", self._on_sheet_double_click) # Сохранить состояние для undo (для активной вкладки) tab = self._get_active_table_tab() if tab: tab["undo_stack"].append(copy.deepcopy(tab["table_data"])) if len(tab["undo_stack"]) > self._max_undo: tab["undo_stack"].pop(0) tab["redo_stack"].clear() def _on_sheet_double_click(self, event): """Обработка двойного клика для редактирования ячейки клиента.""" region = self.table_tree.identify_region(event.x, event.y) if region != "table": return row = self.table_tree.identify_row(event.y) col = self.table_tree.identify_column(event.x) if row is None or col is None: return # Служебные строки в конце if row >= len(self.flower_list): return # Редактируем только колонки клиентов (индексы 5 и далее) if col < 5: return client_name = list(self.table_clients)[col - 5] flower_en = self.table_tree.get_cell_data(row, 0) current_value = self.table_tree.get_cell_data(row, col) new_value = simpledialog.askstring( "Изменить количество", f"Клиент: {client_name}\nЦветок: {flower_en}\nКоличество:", initialvalue=str(current_value), parent=self.root ) if new_value is None: return try: boxes = int(new_value.strip()) if new_value.strip() else 0 except ValueError: messagebox.showerror("Ошибка", "Введите целое число") return # Сохранить состояние для undo tab = self._get_active_table_tab() if tab: tab["undo_stack"].append(copy.deepcopy(self.table_data)) if len(tab["undo_stack"]) > self._max_undo: tab["undo_stack"].pop(0) tab["redo_stack"].clear() flower_upper = flower_en.upper() if flower_upper not in self.table_data: self.table_data[flower_upper] = {} self.table_data[flower_upper][client_name] = boxes # Синхронизировать с вкладкой if tab: tab["table_data"] = self.table_data # Перестроить таблицу self._build_visual_table() def _recalculate_row_total(self, item, values): """Пересчитать итог строки (K-VO).""" columns = self.table_tree["columns"] # Найти индекс колонки kvo kvo_idx = columns.index("kvo") if "kvo" in columns else -1 if kvo_idx < 0: return # Суммировать значения клиентов (пропустить flower_en, flower_ru, nl, code) total = 0 for i, col in enumerate(columns): if col not in ["flower_en", "flower_ru", "nl", "code", "kvo"]: try: val = int(values[i]) if values[i] else 0 total += val except (ValueError, TypeError): pass values[kvo_idx] = str(total) if total > 0 else "" self.table_tree.item(item, values=values) def _update_totals_row(self): """Обновить строку ИТОГО в таблице.""" columns = self.table_tree["columns"] children = self.table_tree.get_children() if not children: return # Найти строку ИТОГО (последняя строка с тегом 'total') total_item = None for child in children: tags = self.table_tree.item(child, "tags") if "total" in tags: total_item = child break if not total_item: return # Найти индексы нужных колонок kvo_idx = columns.index("kvo") if "kvo" in columns else -1 client_indices = [] for i, col in enumerate(columns): if col not in ["flower_en", "flower_ru", "nl", "code", "kvo"]: client_indices.append((i, col)) # Суммировать значения по всем строкам (кроме ИТОГО) new_total_values = ["ИТОГО", "", "", ""] grand_total = 0 # Сначала вычислить grand_total для К-ВО for col_idx, col_name in client_indices: for child in children: tags = self.table_tree.item(child, "tags") if "total" in tags or "clients_row" in tags: continue values = self.table_tree.item(child, "values") if col_idx < len(values): try: val = int(values[col_idx]) if values[col_idx] else 0 grand_total += val except (ValueError, TypeError): pass new_total_values.append(str(grand_total) if grand_total > 0 else "") # К-ВО # Теперь значения по каждому клиенту for col_idx, col_name in client_indices: col_total = 0 for child in children: tags = self.table_tree.item(child, "tags") if "total" in tags or "clients_row" in tags: continue values = self.table_tree.item(child, "values") if col_idx < len(values): try: val = int(values[col_idx]) if values[col_idx] else 0 col_total += val except (ValueError, TypeError): pass new_total_values.append(str(col_total) if col_total > 0 else "") self.table_tree.item(total_item, values=new_total_values) # Обновить строку "Клиентов" clients_item = None for child in children: tags = self.table_tree.item(child, "tags") if "clients_row" in tags: clients_item = child break if clients_item: clients_count_values = ["Клиентов", "", "", ""] clients_count_values.append("") # К-ВО пустое for col_idx, col_name in client_indices: # Подсчитать сколько строк имеют ненулевое значение для этого клиента flowers_count = 0 for child in children: tags = self.table_tree.item(child, "tags") if "total" in tags or "clients_row" in tags: continue values = self.table_tree.item(child, "values") if col_idx < len(values): try: val = int(values[col_idx]) if values[col_idx] else 0 if val > 0: flowers_count += 1 except (ValueError, TypeError): pass clients_count_values.append(str(flowers_count) if flowers_count > 0 else "") self.table_tree.item(clients_item, values=clients_count_values) # Обновить информацию self.table_info_label.config( text=f"Строк: {len(self.flower_list)} | Клиентов: {len(self.table_clients)} | Всего коробок: {grand_total}" ) # === Поиск и контекстное меню таблицы === def _on_search_changed(self, event=None): """Обработка изменения в поле поиска.""" search_text = self.search_var.get().strip() if len(search_text) >= 2: self._search_flower() elif not search_text: self._reset_search() def _search_flower(self): """Поиск цветка в таблице.""" search_text = self.search_var.get().strip().upper() if not search_text: return total_rows = self.table_tree.get_total_rows() # Снять предыдущее выделение поиска self._reset_search_tags() # Найти и выделить совпадающие строки found_rows = [] for row in range(total_rows): values = self.table_tree.get_row_data(row) if values: flower_en = str(values[0]).upper() flower_ru = str(values[1]).upper() if search_text in flower_en or search_text in flower_ru: found_rows.append(row) if found_rows: # Выделить найденные строки for row in found_rows: self.table_tree.highlight_rows(rows=row, bg="#90EE90") # Прокрутить к первому найденному self.table_tree.see(found_rows[0], 0) self.table_tree.select_row(found_rows[0]) self.table_info_label.config(text=f"Найдено: {len(found_rows)} строк") else: self.table_info_label.config(text="Ничего не найдено") def _reset_search(self): """Сбросить поиск.""" self.search_var.set("") self._reset_search_tags() # Перестроить таблицу для восстановления цветов self._build_visual_table() def _reset_search_tags(self): """Снять выделение поиска - перестроить таблицу.""" pass def _get_selected_table_row(self): """Получить индекс текущей выделенной строки в tksheet.""" # Сначала проверить сохранённую строку из контекстного меню row = getattr(self, "_selected_table_row", None) if row is not None: return row # Иначе взять из текущего выделения selected = self.table_tree.get_currently_selected() if not selected: return None return getattr(selected, "row", None) def _open_in_excel(self): """Сохранить текущую таблицу в XLSM и открыть в Excel.""" if not self.flower_list: messagebox.showwarning("Внимание", "Таблица пуста. Сначала загрузите инвойсы.") return # Создать временный файл XLSM temp_path = os.path.join(os.getcwd(), "temp_europe_table.xlsm") try: # Сохранить текущее состояние таблицы self._save_table_to_xlsm(temp_path) # Открыть в Excel if os.name == 'nt': # Windows os.startfile(temp_path) else: subprocess.call(['open', temp_path]) # macOS self.status_var.set(f"Таблица сохранена в {temp_path}") messagebox.showinfo("Excel открыт", f"Таблица сохранена во временный файл:\n{temp_path}\n\nОтредактируйте в Excel, затем нажмите 'Загрузить из Excel'.") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось открыть Excel:\n{e}") def _load_from_excel(self): """Загрузить данные из отредактированного XLSM файла.""" temp_path = os.path.join(os.getcwd(), "temp_europe_table.xlsm") if not os.path.exists(temp_path): # Если временного файла нет, открыть диалог выбора temp_path = filedialog.askopenfilename( title="Выберите XLSM файл", filetypes=[("Excel файлы", "*.xlsm"), ("Все файлы", "*.*")] ) if not temp_path: return try: # Загрузить данные из XLSM self._load_table_from_xlsm(temp_path) self.status_var.set(f"Данные загружены из {temp_path}") messagebox.showinfo("Успех", "Данные успешно загружены из Excel.") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось загрузить данные из Excel:\n{e}") def _save_table_to_xlsm(self, filepath: str): """Сохранить текущую таблицу в XLSM файл.""" import openpyxl from openpyxl.styles import PatternFill, Alignment, Font wb = openpyxl.Workbook() ws = wb.active ws.title = "Европа" # Заголовки столбцов headers = ["Цветок (EN)", "Цветок (RU)", "NL", "Код"] + self.table_clients for col_idx, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=header) cell.font = Font(bold=True) cell.alignment = Alignment(horizontal="center") # Данные строк for row_idx, flower_en in enumerate(self.flower_list, 2): flower_data = self.flower_full_data.get(flower_en, {}) # Основные колонки ws.cell(row=row_idx, column=1, value=flower_data.get('name_en', flower_en)) ws.cell(row=row_idx, column=2, value=flower_data.get('name_ru', '')) ws.cell(row=row_idx, column=3, value=flower_data.get('nl', '')) ws.cell(row=row_idx, column=4, value=flower_data.get('code', '')) # Колонки клиентов for client_idx, client in enumerate(self.table_clients, 5): boxes = self.table_data.get(flower_en, {}).get(client, 0) ws.cell(row=row_idx, column=client_idx, value=boxes) # Окрашивание строки (если есть пользовательский цвет) user_color = self.flower_db.get_flower_color(flower_en) if user_color: fill = PatternFill(start_color=user_color.lstrip('#'), end_color=user_color.lstrip('#'), fill_type="solid") text_color = self._get_contrast_text_color(user_color) for col_idx in range(1, len(headers) + 1): cell = ws.cell(row=row_idx, column=col_idx) cell.fill = fill cell.font = Font(color=text_color.lstrip('#')) # Сохранить wb.save(filepath) def _load_table_from_xlsm(self, filepath: str): """Загрузить данные из XLSM файла обратно в программу.""" import openpyxl wb = openpyxl.load_workbook(filepath) ws = wb.active # Прочитать заголовки (клиенты) self.table_clients = [] for col_idx in range(5, ws.max_column + 1): client = ws.cell(row=1, column=col_idx).value if client: self.table_clients.append(client) # Прочитать данные строк self.flower_list = [] self.table_data = {} self.flower_full_data = {} for row_idx in range(2, ws.max_row + 1): flower_en = ws.cell(row=row_idx, column=1).value if not flower_en: continue self.flower_list.append(flower_en) # Основные данные self.flower_full_data[flower_en] = { 'name_en': flower_en, 'name_ru': ws.cell(row=row_idx, column=2).value or '', 'nl': ws.cell(row=row_idx, column=3).value or '', 'code': ws.cell(row=row_idx, column=4).value or '', 'bg_color': None } # Данные клиентов self.table_data[flower_en] = {} for client_idx, client in enumerate(self.table_clients, 5): boxes = ws.cell(row=row_idx, column=client_idx).value self.table_data[flower_en][client] = boxes if boxes else 0 # Прочитать цвет строки (если есть) first_cell = ws.cell(row=row_idx, column=1) if first_cell.fill and first_cell.fill.start_color and first_cell.fill.start_color.rgb: color_rgb = first_cell.fill.start_color.rgb if color_rgb and color_rgb != '00000000': # Преобразовать в hex if len(color_rgb) == 8: # AARRGGBB color_hex = '#' + color_rgb[2:] else: color_hex = '#' + color_rgb self.flower_full_data[flower_en]['bg_color'] = color_hex # Сохранить в flower_db self.flower_db.set_flower_color(flower_en, color_hex) # Обновить таблицу self._build_visual_table() def _get_contrast_text_color(self, bg_color: str) -> str: """Вычислить контрастный цвет текста для заданного фона.""" try: bg = bg_color.lstrip('#') if len(bg) == 8: # AARRGGBB bg = bg[2:] r = int(bg[0:2], 16) g = int(bg[2:4], 16) b = int(bg[4:6], 16) brightness = (r * 299 + g * 587 + b * 114) / 1000 return "#000000" if brightness >= 128 else "#FFFFFF" except Exception: return "#000000" def _add_synonym_from_invoice(self): """Добавить синоним — из нераспознанных названий или вручную.""" row = self._get_selected_table_row() if row is None: messagebox.showinfo("Информация", "Выберите строку в таблице") return values = self.table_tree.get_row_data(row) if not values: return flower_en = values[0] if not flower_en: return # Собрать нераспознанные названия из aggregated_results unmatched_names = set() for res in self.aggregated_results: if not res.get("is_matched", False): flower_name = res.get("flower", "") if flower_name and flower_name != "НЕИЗВЕСТНО": unmatched_names.add(flower_name) # Диалог добавления синонима dialog = Toplevel(self.root) dialog.title("Добавить синоним") dialog.geometry("550x450") dialog.transient(self.root) dialog.grab_set() ttk.Label(dialog, text=f"Цветок: {flower_en}", font=("Arial", 11, "bold")).pack(anchor="w", padx=20, pady=(15, 5)) # Ручной ввод manual_frame = ttk.LabelFrame(dialog, text="Ввести вручную", padding="10") manual_frame.pack(fill="x", padx=20, pady=(5, 10)) ttk.Label(manual_frame, text="Название для добавления как синоним:").pack(anchor="w") manual_entry = ttk.Entry(manual_frame, width=50) manual_entry.pack(fill="x", pady=(5, 5)) # Список нераспознанных (если есть) listbox = None sorted_names = [] if unmatched_names: list_frame = ttk.LabelFrame(dialog, text=f"Нераспознанные из инвойсов ({len(unmatched_names)})", padding="10") list_frame.pack(fill="both", expand=True, padx=20, pady=(0, 10)) ttk.Label(list_frame, text="Или выберите из списка:").pack(anchor="w") listbox = Listbox(list_frame, width=60, height=10) listbox.pack(fill="both", expand=True, pady=(5, 0)) sorted_names = sorted(unmatched_names) for name in sorted_names: listbox.insert("end", name) # При выборе из списка — заполнять поле ввода def on_listbox_select(event): sel = listbox.curselection() if sel: manual_entry.delete(0, "end") manual_entry.insert(0, sorted_names[sel[0]]) listbox.bind("<<ListboxSelect>>", on_listbox_select) def confirm(): synonym_name = manual_entry.get().strip() if not synonym_name: # Если поле пустое, проверить выбор в списке if listbox: sel = listbox.curselection() if sel: synonym_name = sorted_names[sel[0]] if not synonym_name: messagebox.showwarning("Внимание", "Введите или выберите название для добавления", parent=dialog) return # Найти цветок в базе flower_obj = None for f in self.flower_db.get_all_flowers(): if f.full_name_en.upper() == flower_en.upper(): flower_obj = f break if flower_obj: # Добавить синоним self.flower_db.add_synonym(flower_obj, synonym_name) messagebox.showinfo("Успех", f"Добавлен синоним:\n'{synonym_name}' → '{flower_en}'\n\nПри следующем сканировании это название будет распознано автоматически.") dialog.destroy() else: messagebox.showerror("Ошибка", f"Цветок '{flower_en}' не найден в базе") btn_frame = ttk.Frame(dialog) btn_frame.pack(pady=10) ttk.Button(btn_frame, text="Сохранить", command=confirm).pack(side="left", padx=(0, 5)) ttk.Button(btn_frame, text="Отмена", command=dialog.destroy).pack(side="left") def _show_flower_synonyms(self): """Показать синонимы выбранного цветка.""" row = self._get_selected_table_row() if row is None: messagebox.showinfo("Информация", "Выберите строку в таблице") return values = self.table_tree.get_row_data(row) if not values: return flower_en = values[0] if not flower_en: return # Найти цветок в базе flower_obj = None for f in self.flower_db.get_all_flowers(): if f.full_name_en.upper() == flower_en.upper(): flower_obj = f break if not flower_obj: messagebox.showinfo("Информация", f"Цветок '{flower_en}' не найден в базе") return # Показать информацию synonyms_text = "\n".join(flower_obj.synonyms) if flower_obj.synonyms else "(нет синонимов)" messagebox.showinfo( f"Синонимы: {flower_en}", f"Цветок: {flower_obj.full_name_en}\n" f"Русское название: {flower_obj.full_name_ru}\n\n" f"Синонимы ({len(flower_obj.synonyms)}):\n{synonyms_text}" ) @staticmethod def _get_contrast_text_color(bg_color: str) -> str: """Определить белый или чёрный текст для контраста с фоном.""" try: bg_color = bg_color.lstrip("#") if len(bg_color) == 8: bg_color = bg_color[2:] if len(bg_color) != 6: return "black" r = int(bg_color[0:2], 16) g = int(bg_color[2:4], 16) b = int(bg_color[4:6], 16) # Формула яркости brightness = (r * 299 + g * 587 + b * 114) / 1000 return "white" if brightness < 128 else "black" except Exception: return "black" def _change_row_color(self): """Изменить цвет фона строки через диалог выбора цвета.""" row = self._get_selected_table_row() if row is None: messagebox.showinfo("Информация", "Выберите строку в таблице") return values = self.table_tree.get_row_data(row) if not values: return flower_en = values[0] if not flower_en: return # Открыть диалог выбора цвета color = colorchooser.askcolor(title=f"Выберите цвет для '{flower_en}'")[1] if not color: return # Сохранить цвет в базе self.flower_db.set_flower_color(flower_en, color) # Перестроить таблицу с новым цветом self._build_visual_table() def _reset_row_color(self): """Сбросить цвет фона строки.""" row = self._get_selected_table_row() if row is None: messagebox.showinfo("Информация", "Выберите строку в таблице") return values = self.table_tree.get_row_data(row) if not values: return flower_en = values[0] if not flower_en: return # Сбросить цвет в базе self.flower_db.set_flower_color(flower_en, None) # Перестроить таблицу self._build_visual_table() # === Экспорт и запись === def _write_to_base(self): """Записать данные сессии в базовую таблицу XLSM.""" if not self.current_session_id or not self.base_config: messagebox.showwarning("Внимание", "Нет активной сессии или базовой таблицы!") return session_data = self._table_data_to_session_data() if not session_data: messagebox.showwarning("Внимание", "Нет данных для записи!") return msg = f"Записать {len(session_data)} позиций в базовую таблицу?\n\n(Таблица будет очищена и заполнена заново)" if not messagebox.askyesno("Подтверждение", msg): return # Проверить доступность файла xlsm_path = self.base_config["xlsm_path"] if not os.path.exists(xlsm_path): messagebox.showerror("Ошибка", f"Файл не найден:\n{xlsm_path}\n\nЗагрузите базовую таблицу заново.") return try: # Загрузить таблицу if not self.excel_manager.load_table(xlsm_path): messagebox.showerror("Ошибка", "Не удалось загрузить таблицу!") return # Записать (очистить + заполнить) success, errors = self.excel_manager.write_session_to_base(session_data) # Сохранить if self.excel_manager.save(): self.db.update_session_status(self.current_session_id, "sent") messagebox.showinfo("Результат", f"Записано: {success}\nОшибок: {errors}") self._update_status(f"Записано в базу: {success} позиций") else: messagebox.showerror("Ошибка", "Не удалось сохранить таблицу! (Возможно, файл открыт в Excel)") except PermissionError: messagebox.showerror("Ошибка", "Файл открыт в Excel! Закройте его и попробуйте снова.") def _export_to_excel(self): """Экспорт визуальной таблицы в новый Excel файл.""" if not self.table_data: messagebox.showwarning("Внимание", "Нет данных для экспорта!") return default_name = f"Отчет_{datetime.now().strftime('%Y-%m-%d_%H-%M')}.xlsx" file_path = filedialog.asksaveasfilename( title="Сохранить отчет", defaultextension=".xlsx", initialfile=default_name, filetypes=[("Excel файлы", "*.xlsx"), ("Все файлы", "*.*")] ) if not file_path: return try: from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side wb = Workbook() ws = wb.active ws.title = "Отчет по коробкам" header_font = Font(bold=True, size=10) header_fill = PatternFill(start_color="E0E0E0", end_color="E0E0E0", fill_type="solid") total_fill = PatternFill(start_color="FFC107", end_color="FFC107", fill_type="solid") green_fill = PatternFill(start_color="D4EDDA", end_color="D4EDDA", fill_type="solid") center_align = Alignment(horizontal="center", vertical="center") thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) ws.cell(row=1, column=1, value="Цветок / Клиент").font = header_font ws.cell(row=1, column=1).fill = header_fill ws.cell(row=1, column=1).border = thin_border for col_idx, client in enumerate(self.table_clients): cell = ws.cell(row=1, column=col_idx + 2, value=client) cell.font = header_font cell.fill = header_fill cell.alignment = center_align cell.border = thin_border total_col = len(self.table_clients) + 2 cell = ws.cell(row=1, column=total_col, value="ИТОГО") cell.font = header_font cell.fill = total_fill cell.alignment = center_align cell.border = thin_border for row_idx, flower in enumerate(self.table_flowers): ws.cell(row=row_idx + 2, column=1, value=flower).font = header_font ws.cell(row=row_idx + 2, column=1).border = thin_border row_total = 0 for col_idx, client in enumerate(self.table_clients): boxes = self.table_data.get(flower, {}).get(client, 0) row_total += boxes cell = ws.cell(row=row_idx + 2, column=col_idx + 2, value=boxes if boxes > 0 else "") cell.alignment = center_align cell.border = thin_border if boxes > 0: cell.fill = green_fill cell = ws.cell(row=row_idx + 2, column=total_col, value=row_total) cell.font = header_font cell.fill = PatternFill(start_color="FFF3CD", end_color="FFF3CD", fill_type="solid") cell.alignment = center_align cell.border = thin_border total_row = len(self.table_flowers) + 2 ws.cell(row=total_row, column=1, value="ИТОГО").font = header_font ws.cell(row=total_row, column=1).fill = total_fill ws.cell(row=total_row, column=1).border = thin_border grand_total = 0 for col_idx, client in enumerate(self.table_clients): client_total = sum(self.table_data.get(f, {}).get(client, 0) for f in self.table_flowers) grand_total += client_total cell = ws.cell(row=total_row, column=col_idx + 2, value=client_total) cell.font = header_font cell.fill = PatternFill(start_color="FFF3CD", end_color="FFF3CD", fill_type="solid") cell.alignment = center_align cell.border = thin_border cell = ws.cell(row=total_row, column=total_col, value=grand_total) cell.font = Font(bold=True, size=11, color="FFFFFF") cell.fill = PatternFill(start_color="FF9800", end_color="FF9800", fill_type="solid") cell.alignment = center_align cell.border = thin_border for col in ws.columns: max_length = max((len(str(c.value)) if c.value else 0) for c in col) ws.column_dimensions[col[0].column_letter].width = max(max_length + 3, 12) wb.save(file_path) # Открыть файл в Excel через ExcelWidget if WIN32_AVAILABLE: tab_name = os.path.splitext(os.path.basename(file_path))[0] self._create_excel_tab(tab_name, file_path) self._update_status(f"Отчет экспортирован и открыт в Excel: {os.path.basename(file_path)}") else: messagebox.showinfo("Успех", f"Отчет сохранен:\n{file_path}") except Exception as e: messagebox.showerror("Ошибка", f"Ошибка экспорта:\n{e}") def _send_email(self): """Отправить результаты на почту (заготовка).""" if not self.table_data: messagebox.showwarning("Внимание", "Нет данных для отправки!") return dialog = Toplevel(self.root) dialog.title("Отправить на почту") dialog.geometry("450x250") dialog.transient(self.root) dialog.grab_set() ttk.Label(dialog, text="Email получателя:").pack(anchor="w", padx=20, pady=(20, 5)) email_entry = ttk.Entry(dialog, width=50) email_entry.pack(anchor="w", padx=20) ttk.Label(dialog, text="Тема:").pack(anchor="w", padx=20, pady=(10, 5)) subject_entry = ttk.Entry(dialog, width=50) subject_entry.insert(0, f"Отчет {self.current_session_name} ({datetime.now().strftime('%d.%m.%Y')})") subject_entry.pack(anchor="w", padx=20) def send(): email = email_entry.get().strip() if not email: messagebox.showwarning("Внимание", "Введите email!", parent=dialog) return messagebox.showinfo("Отправка", f"Функция отправки на {email} будет реализована позже.", parent=dialog) dialog.destroy() ttk.Button(dialog, text="Отправить", command=send).pack(pady=20) # === Вспомогательные методы === def _table_data_to_session_data(self) -> list: """Конвертировать table_data в формат для БД.""" data = [] for flower, clients in self.table_data.items(): for client, boxes in clients.items(): if boxes > 0: data.append({"flower_name": flower, "client_name": client, "boxes_count": boxes}) return data def _session_data_to_table_data(self, session_data: list) -> tuple: """Конвертировать данные из БД в table_data.""" table_data = {} for entry in session_data: flower = entry["flower_name"] client = entry["client_name"] boxes = entry["boxes_count"] if flower not in table_data: table_data[flower] = {} table_data[flower][client] = boxes flowers = sorted(table_data.keys()) clients = sorted({c for f_data in table_data.values() for c in f_data.keys()}) return table_data, flowers, clients def _add_flower_dialog(self): """Диалог добавления нового цветка в базу.""" dialog = Toplevel(self.root) dialog.title("Добавить цветок") dialog.geometry("400x250") dialog.transient(self.root) dialog.grab_set() ttk.Label(dialog, text="Название на латинице:").pack(anchor="w", padx=20, pady=(20, 0)) en_entry = ttk.Entry(dialog, width=40) en_entry.pack(anchor="w", padx=20) ttk.Label(dialog, text="Название на русском:").pack(anchor="w", padx=20, pady=(10, 0)) ru_entry = ttk.Entry(dialog, width=40) ru_entry.pack(anchor="w", padx=20) ttk.Label(dialog, text="Синонимы (через запятую):").pack(anchor="w", padx=20, pady=(10, 0)) syn_entry = ttk.Entry(dialog, width=40) syn_entry.pack(anchor="w", padx=20) def save(): en_name = en_entry.get().strip() ru_name = ru_entry.get().strip() synonyms = [s.strip() for s in syn_entry.get().split(",") if s.strip()] if not en_name or not ru_name: messagebox.showwarning("Внимание", "Заполните оба названия!", parent=dialog) return self.flower_db.add_flower(en_name, ru_name, synonyms) messagebox.showinfo("Успех", f"Цветок '{en_name}' добавлен!", parent=dialog) dialog.destroy() ttk.Button(dialog, text="Сохранить", command=save).pack(pady=20) def _show_context_menu(self, event): selection = self.tree.selection() if selection: self.context_menu.post(event.x_root, event.y_root) def _on_double_click(self, event): """Двойной клик - открыть файл инвойса.""" self._open_invoice_file() def _open_invoice_file(self): """Открыть файл инвойса в программе по умолчанию.""" selection = self.tree.selection() if not selection: messagebox.showinfo("Информация", "Выберите запись в списке") return item_id = selection[0] idx = int(item_id) res = self.aggregated_results[idx] file_paths = res.get("file_paths", []) if not file_paths: messagebox.showwarning("Внимание", "Файл не найден") return # Если несколько файлов, показать диалог выбора if len(file_paths) > 1: dialog = Toplevel(self.root) dialog.title("Выберите файл") dialog.geometry("500x300") dialog.transient(self.root) dialog.grab_set() ttk.Label(dialog, text="Несколько файлов. Выберите для открытия:").pack(anchor="w", padx=20, pady=(15, 5)) listbox = Listbox(dialog, width=60, height=10) listbox.pack(fill="both", expand=True, padx=20, pady=10) for fp in file_paths: listbox.insert("end", os.path.basename(fp)) def open_selected(): sel = listbox.curselection() if sel: self._open_file_with_default(file_paths[sel[0]]) dialog.destroy() ttk.Button(dialog, text="Открыть", command=open_selected).pack(pady=10) else: self._open_file_with_default(file_paths[0]) def _open_file_with_default(self, file_path): """Открыть файл в программе по умолчанию.""" if not os.path.exists(file_path): messagebox.showerror("Ошибка", f"Файл не найден:\n{file_path}") return try: os.startfile(file_path) except Exception as e: messagebox.showerror("Ошибка", f"Не удалось открыть файл:\n{e}") def _match_flower_manual(self): """Ручное сопоставление цветка.""" selection = self.tree.selection() if not selection: return item_id = selection[0] idx = int(item_id) res = self.aggregated_results[idx] dialog = Toplevel(self.root) dialog.title("Сопоставить цветок") dialog.geometry("450x400") dialog.transient(self.root) dialog.grab_set() ttk.Label(dialog, text=f"Исходное: {res.get('flower', '')}").pack(anchor="w", padx=20, pady=(20, 10)) listbox = Listbox(dialog, width=50, height=15) listbox.pack(fill="both", expand=True, padx=20, pady=10) flowers = self.flower_db.get_all_flowers() for f in flowers: listbox.insert("end", f"{f.full_name_en} ({f.full_name_ru})") def confirm(): sel = listbox.curselection() if not sel: return selected = flowers[sel[0]] old_flower = res.get("flower", "") res["flower"] = selected.full_name_en res["is_matched"] = True # Обновить список values = list(self.tree.item(item_id, "values")) values[1] = selected.full_name_en values[4] = "Распознан (вручную)" self.tree.item(item_id, values=values) # Обновить table_data client = res.get("client", "") boxes = res.get("boxes", 0) if old_flower in self.table_data: old_data = self.table_data[old_flower].pop(client, 0) if not self.table_data[old_flower]: del self.table_data[old_flower] if selected.full_name_en not in self.table_data: self.table_data[selected.full_name_en] = {} self.table_data[selected.full_name_en][client] = boxes self.table_flowers = sorted(self.table_data.keys()) self._build_visual_table() self.flower_db.add_synonym(selected, old_flower) dialog.destroy() ttk.Button(dialog, text="Подтвердить", command=confirm).pack(pady=10) def _edit_client(self): selection = self.tree.selection() if not selection: return item_id = selection[0] idx = int(item_id) res = self.aggregated_results[idx] new_client = simpledialog.askstring("Изменить клиента", "Новое имя:", initialvalue=res.get("client", ""), parent=self.root) if new_client: old_client = res.get("client", "") res["client"] = new_client.strip().upper() values = list(self.tree.item(item_id, "values")) values[0] = res["client"] self.tree.item(item_id, values=values) flower = res.get("flower", "") boxes = res.get("boxes", 0) if flower in self.table_data: self.table_data[flower].pop(old_client, 0) if res["client"] not in self.table_data[flower]: self.table_data[flower][res["client"]] = 0 self.table_data[flower][res["client"]] += boxes self.table_clients = sorted({c for f_data in self.table_data.values() for c in f_data.keys()}) self._build_visual_table() def _delete_selected(self): selection = self.tree.selection() if not selection: return for item_id in reversed(selection): idx = int(item_id) if idx < len(self.aggregated_results): res = self.aggregated_results[idx] flower = res.get("flower", "") client = res.get("client", "") boxes = res.get("boxes", 0) if flower in self.table_data and client in self.table_data[flower]: self.table_data[flower][client] -= boxes if self.table_data[flower][client] <= 0: del self.table_data[flower][client] if not self.table_data[flower]: del self.table_data[flower] del self.aggregated_results[idx] self.tree.delete(item_id) self.table_flowers = sorted(self.table_data.keys()) self.table_clients = sorted({c for f_data in self.table_data.values() for c in f_data.keys()}) self._build_visual_table() def _update_status(self, message: str): self.status_var.set(message) def _show_about_dialog(self): """Показать диалог 'О программе' с версией.""" dialog = Toplevel(self.root) dialog.title("О программе") dialog.geometry("340x180") dialog.resizable(False, False) dialog.transient(self.root) dialog.grab_set() # Центрировать dialog.update_idletasks() x = (dialog.winfo_screenwidth() - dialog.winfo_width()) // 2 y = (dialog.winfo_screenheight() - dialog.winfo_height()) // 2 dialog.geometry(f"+{x}+{y}") ttk.Label(dialog, text="Декларант", font=("Arial", 14, "bold")).pack(pady=(20, 5)) ttk.Label(dialog, text="Обработка инвойсов и таможенных документов").pack() ver = get_version() ttk.Label(dialog, text=f"Версия: {ver}", foreground="gray", font=("Arial", 10)).pack(pady=(15, 5)) ttk.Button(dialog, text="OK", command=dialog.destroy, width=10).pack(pady=(15, 0)) def run_app(): """Запустить приложение.""" root = Tk() style = ttk.Style() style.theme_use("clam") app = DeclarantApp(root) root.mainloop() if __name__ == "__main__": run_app()