965 lines
34 KiB
Python
965 lines
34 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
GUI приложение для работы с МойСклад
|
||
Генерация XML документов для 1С
|
||
"""
|
||
import sys
|
||
import os
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Optional, List, Dict
|
||
|
||
# Настройка логирования
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QPushButton, QLabel, QLineEdit, QTextEdit, QTableWidget,
|
||
QTableWidgetItem, QTabWidget, QFileDialog, QMessageBox,
|
||
QProgressBar, QGroupBox, QFormLayout, QComboBox, QSpinBox,
|
||
QCheckBox, QDialog, QDialogButtonBox
|
||
)
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||
from PyQt6.QtGui import QFont, QIcon, QPalette, QColor
|
||
|
||
# Добавляем путь к modules
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'modules'))
|
||
|
||
from modules.moysklad_client import MoySkladClient
|
||
from modules.validator import OrderValidator, ValidationLevel
|
||
from modules.xml_generator import XML1CGenerator
|
||
from config import settings
|
||
|
||
|
||
class ThemeManager:
|
||
"""Управление системной темой приложения."""
|
||
|
||
LIGHT = "light"
|
||
DARK = "dark"
|
||
|
||
_original_palette: Optional[QPalette] = None
|
||
_original_style: Optional[str] = None
|
||
_original_stylesheet: Optional[str] = None
|
||
|
||
@staticmethod
|
||
def detect_system_theme() -> str:
|
||
"""Определение системной темы."""
|
||
if sys.platform == "win32":
|
||
try:
|
||
import winreg # type: ignore
|
||
|
||
key_path = (
|
||
r"Software\Microsoft\Windows\CurrentVersion"
|
||
r"\Themes\Personalize"
|
||
)
|
||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key:
|
||
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
||
if value == 0:
|
||
return ThemeManager.DARK
|
||
return ThemeManager.LIGHT
|
||
except OSError as error:
|
||
logger.warning(
|
||
"Не удалось прочитать системную тему Windows: %s",
|
||
error,
|
||
)
|
||
except ImportError:
|
||
logger.warning(
|
||
"Модуль winreg недоступен, используется светлая тема"
|
||
)
|
||
return ThemeManager.LIGHT
|
||
|
||
@classmethod
|
||
def apply(cls, app: QApplication) -> str:
|
||
"""Применение системной темы к приложению."""
|
||
theme = cls.detect_system_theme()
|
||
|
||
if cls._original_palette is None:
|
||
cls._original_palette = app.palette()
|
||
if cls._original_style is None:
|
||
cls._original_style = app.style().objectName()
|
||
if cls._original_stylesheet is None:
|
||
cls._original_stylesheet = app.styleSheet()
|
||
|
||
if theme == cls.DARK:
|
||
cls._apply_dark_theme(app)
|
||
else:
|
||
cls._apply_light_theme(app)
|
||
|
||
logger.info("Применена системная тема: %s", theme)
|
||
return theme
|
||
|
||
@staticmethod
|
||
def _apply_dark_theme(app: QApplication) -> None:
|
||
"""Применение темной темы."""
|
||
palette = QPalette()
|
||
palette.setColor(QPalette.ColorRole.Window, QColor(37, 37, 38))
|
||
palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
|
||
palette.setColor(QPalette.ColorRole.Base, QColor(30, 30, 30))
|
||
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(45, 45, 45))
|
||
palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white)
|
||
palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white)
|
||
palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white)
|
||
palette.setColor(QPalette.ColorRole.Button, QColor(45, 45, 45))
|
||
palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white)
|
||
palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
|
||
palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
|
||
palette.setColor(QPalette.ColorRole.Highlight, QColor(45, 140, 240))
|
||
palette.setColor(
|
||
QPalette.ColorRole.HighlightedText,
|
||
Qt.GlobalColor.black,
|
||
)
|
||
|
||
palette.setColor(
|
||
QPalette.ColorRole.PlaceholderText,
|
||
QColor(200, 200, 200, 120),
|
||
)
|
||
palette.setColor(QPalette.ColorRole.Shadow, QColor(12, 12, 12))
|
||
|
||
app.setStyle("Fusion")
|
||
app.setPalette(palette)
|
||
app.setStyleSheet(
|
||
"QToolTip {"
|
||
" background-color: #2a82da;"
|
||
" color: #ffffff;"
|
||
" border: 1px solid white;"
|
||
" }"
|
||
)
|
||
|
||
@classmethod
|
||
def _apply_light_theme(cls, app: QApplication) -> None:
|
||
"""Возврат к светлой теме системы."""
|
||
if cls._original_style:
|
||
app.setStyle(cls._original_style)
|
||
if cls._original_palette:
|
||
app.setPalette(cls._original_palette)
|
||
if cls._original_stylesheet is not None:
|
||
app.setStyleSheet(cls._original_stylesheet)
|
||
|
||
|
||
class WorkerThread(QThread):
|
||
"""Фоновый поток для долгих операций"""
|
||
progress = pyqtSignal(int, str)
|
||
finished = pyqtSignal(object)
|
||
error = pyqtSignal(str)
|
||
|
||
def __init__(self, func, *args, **kwargs):
|
||
super().__init__()
|
||
self.func = func
|
||
self.args = args
|
||
self.kwargs = kwargs
|
||
|
||
def run(self):
|
||
try:
|
||
logger.info(f"Запуск фоновой задачи: {self.func.__name__}")
|
||
result = self.func(*self.args, **self.kwargs)
|
||
logger.info(f"Задача завершена: {self.func.__name__}")
|
||
self.finished.emit(result)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"Ошибка в задаче {self.func.__name__}: {e}",
|
||
exc_info=True
|
||
)
|
||
self.error.emit(str(e))
|
||
|
||
|
||
class LoginDialog(QDialog):
|
||
"""Диалог авторизации"""
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Авторизация МойСклад")
|
||
self.setModal(True)
|
||
self.setMinimumWidth(400)
|
||
|
||
layout = QFormLayout()
|
||
|
||
self.login_input = QLineEdit()
|
||
self.login_input.setPlaceholderText("Логин")
|
||
layout.addRow("Логин:", self.login_input)
|
||
|
||
self.password_input = QLineEdit()
|
||
self.password_input.setPlaceholderText("Пароль")
|
||
self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||
layout.addRow("Пароль:", self.password_input)
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok |
|
||
QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addRow(buttons)
|
||
|
||
self.setLayout(layout)
|
||
|
||
def get_credentials(self):
|
||
return self.login_input.text(), self.password_input.text()
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""Главное окно приложения"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.client: Optional[MoySkladClient] = None
|
||
self.validator = OrderValidator()
|
||
self.xml_generator = XML1CGenerator()
|
||
self.current_orders: List[Dict] = []
|
||
self.selected_order_data: Optional[Dict] = None
|
||
self.worker_thread: Optional[WorkerThread] = None
|
||
self.current_page = 0
|
||
self.page_size = 25
|
||
self.total_orders = 0
|
||
|
||
self.init_ui()
|
||
self.check_credentials()
|
||
|
||
def init_ui(self):
|
||
"""Инициализация UI"""
|
||
self.setWindowTitle("МойСклад - Генератор документов")
|
||
self.setGeometry(100, 100, 1200, 800)
|
||
|
||
# Установка иконки приложения
|
||
icon_path = os.path.join(os.path.dirname(__file__), 'app_icon.png')
|
||
if os.path.exists(icon_path):
|
||
self.setWindowIcon(QIcon(icon_path))
|
||
|
||
# Центральный виджет
|
||
central_widget = QWidget()
|
||
self.setCentralWidget(central_widget)
|
||
|
||
# Главный layout
|
||
main_layout = QVBoxLayout()
|
||
central_widget.setLayout(main_layout)
|
||
|
||
# Строка статуса
|
||
status_layout = QHBoxLayout()
|
||
self.status_label = QLabel("Не авторизован")
|
||
self.status_label.setStyleSheet("color: red; font-weight: bold;")
|
||
status_layout.addWidget(self.status_label)
|
||
status_layout.addStretch()
|
||
|
||
self.login_btn = QPushButton("Войти")
|
||
self.login_btn.clicked.connect(self.show_login_dialog)
|
||
status_layout.addWidget(self.login_btn)
|
||
|
||
self.logout_btn = QPushButton("Выйти")
|
||
self.logout_btn.clicked.connect(self.logout)
|
||
self.logout_btn.setEnabled(False)
|
||
status_layout.addWidget(self.logout_btn)
|
||
|
||
main_layout.addLayout(status_layout)
|
||
|
||
# Tabs
|
||
self.tabs = QTabWidget()
|
||
main_layout.addWidget(self.tabs)
|
||
|
||
# Вкладка поиска
|
||
self.search_tab = self.create_search_tab()
|
||
self.tabs.addTab(self.search_tab, "🔍 Поиск")
|
||
|
||
# Вкладка списка
|
||
self.list_tab = self.create_list_tab()
|
||
self.tabs.addTab(self.list_tab, "📋 Список отгрузок")
|
||
|
||
# Вкладка массовой генерации
|
||
self.batch_tab = self.create_batch_tab()
|
||
self.tabs.addTab(self.batch_tab, "📦 Массовая генерация")
|
||
|
||
# Progress bar
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setVisible(False)
|
||
main_layout.addWidget(self.progress_bar)
|
||
|
||
def create_search_tab(self) -> QWidget:
|
||
"""Создание вкладки поиска"""
|
||
widget = QWidget()
|
||
layout = QVBoxLayout()
|
||
|
||
# Поиск
|
||
search_group = QGroupBox("Поиск отгрузки")
|
||
search_layout = QHBoxLayout()
|
||
|
||
self.search_input = QLineEdit()
|
||
self.search_input.setPlaceholderText(
|
||
"Введите номер отгрузки..."
|
||
)
|
||
self.search_input.returnPressed.connect(self.search_orders)
|
||
search_layout.addWidget(self.search_input)
|
||
|
||
self.search_btn = QPushButton("Найти")
|
||
self.search_btn.clicked.connect(self.search_orders)
|
||
search_layout.addWidget(self.search_btn)
|
||
|
||
search_group.setLayout(search_layout)
|
||
layout.addWidget(search_group)
|
||
|
||
# Результаты
|
||
results_group = QGroupBox("Результаты")
|
||
results_layout = QVBoxLayout()
|
||
|
||
self.search_results_table = QTableWidget()
|
||
self.search_results_table.setColumnCount(5)
|
||
self.search_results_table.setHorizontalHeaderLabels([
|
||
"№", "Дата", "Контрагент", "Сумма", "Действия"
|
||
])
|
||
self.search_results_table.horizontalHeader().setStretchLastSection(
|
||
True
|
||
)
|
||
self.search_results_table.setSelectionBehavior(
|
||
QTableWidget.SelectionBehavior.SelectRows
|
||
)
|
||
results_layout.addWidget(self.search_results_table)
|
||
|
||
results_group.setLayout(results_layout)
|
||
layout.addWidget(results_group)
|
||
|
||
widget.setLayout(layout)
|
||
return widget
|
||
|
||
def create_list_tab(self) -> QWidget:
|
||
"""Создание вкладки списка"""
|
||
widget = QWidget()
|
||
layout = QVBoxLayout()
|
||
|
||
# Кнопки управления
|
||
controls_layout = QHBoxLayout()
|
||
|
||
self.load_list_btn = QPushButton("Загрузить список")
|
||
self.load_list_btn.clicked.connect(self.load_orders_list)
|
||
controls_layout.addWidget(self.load_list_btn)
|
||
|
||
controls_layout.addStretch()
|
||
|
||
self.prev_page_btn = QPushButton("← Предыдущая")
|
||
self.prev_page_btn.clicked.connect(self.prev_page)
|
||
self.prev_page_btn.setEnabled(False)
|
||
controls_layout.addWidget(self.prev_page_btn)
|
||
|
||
self.page_label = QLabel("Страница: 1")
|
||
controls_layout.addWidget(self.page_label)
|
||
|
||
self.next_page_btn = QPushButton("Следующая →")
|
||
self.next_page_btn.clicked.connect(self.next_page)
|
||
self.next_page_btn.setEnabled(False)
|
||
controls_layout.addWidget(self.next_page_btn)
|
||
|
||
layout.addLayout(controls_layout)
|
||
|
||
# Таблица
|
||
self.orders_table = QTableWidget()
|
||
self.orders_table.setColumnCount(5)
|
||
self.orders_table.setHorizontalHeaderLabels([
|
||
"№", "Дата", "Контрагент", "Сумма", "Действия"
|
||
])
|
||
self.orders_table.horizontalHeader().setStretchLastSection(True)
|
||
self.orders_table.setSelectionBehavior(
|
||
QTableWidget.SelectionBehavior.SelectRows
|
||
)
|
||
layout.addWidget(self.orders_table)
|
||
|
||
widget.setLayout(layout)
|
||
return widget
|
||
|
||
def create_batch_tab(self) -> QWidget:
|
||
"""Создание вкладки массовой генерации"""
|
||
widget = QWidget()
|
||
layout = QVBoxLayout()
|
||
|
||
# Ввод номеров
|
||
input_group = QGroupBox("Номера отгрузок")
|
||
input_layout = QVBoxLayout()
|
||
|
||
info_label = QLabel(
|
||
"Введите номера отгрузок через запятую (максимум 50):"
|
||
)
|
||
input_layout.addWidget(info_label)
|
||
|
||
self.batch_input = QTextEdit()
|
||
self.batch_input.setPlaceholderText(
|
||
"Например: 4926,4927,4928"
|
||
)
|
||
self.batch_input.setMaximumHeight(100)
|
||
input_layout.addWidget(self.batch_input)
|
||
|
||
input_group.setLayout(input_layout)
|
||
layout.addWidget(input_group)
|
||
|
||
# Настройки
|
||
settings_group = QGroupBox("Настройки")
|
||
settings_layout = QFormLayout()
|
||
|
||
self.batch_mode_combo = QComboBox()
|
||
self.batch_mode_combo.addItems([
|
||
"Один файл (все документы)",
|
||
"Отдельные файлы (каждый документ)"
|
||
])
|
||
settings_layout.addRow("Режим:", self.batch_mode_combo)
|
||
|
||
self.validate_checkbox = QCheckBox("Валидировать перед генерацией")
|
||
self.validate_checkbox.setChecked(True)
|
||
settings_layout.addRow("", self.validate_checkbox)
|
||
|
||
settings_group.setLayout(settings_layout)
|
||
layout.addWidget(settings_group)
|
||
|
||
# Кнопка генерации
|
||
self.batch_generate_btn = QPushButton("Сгенерировать")
|
||
self.batch_generate_btn.clicked.connect(self.batch_generate)
|
||
layout.addWidget(self.batch_generate_btn)
|
||
|
||
# Результаты
|
||
results_group = QGroupBox("Результаты")
|
||
results_layout = QVBoxLayout()
|
||
|
||
self.batch_results = QTextEdit()
|
||
self.batch_results.setReadOnly(True)
|
||
results_layout.addWidget(self.batch_results)
|
||
|
||
results_group.setLayout(results_layout)
|
||
layout.addWidget(results_group)
|
||
|
||
widget.setLayout(layout)
|
||
return widget
|
||
|
||
def check_credentials(self):
|
||
"""Проверка сохраненных учетных данных"""
|
||
try:
|
||
self.client = MoySkladClient(
|
||
settings.moysklad_login,
|
||
settings.moysklad_password,
|
||
settings.moysklad_api_url
|
||
)
|
||
# Тестовый запрос
|
||
self.client.get_orders(limit=1)
|
||
self.update_status(True)
|
||
except Exception:
|
||
self.update_status(False)
|
||
|
||
def show_login_dialog(self):
|
||
"""Показ диалога авторизации"""
|
||
dialog = LoginDialog(self)
|
||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||
login, password = dialog.get_credentials()
|
||
self.login(login, password)
|
||
|
||
def login(self, login: str, password: str):
|
||
"""Авторизация"""
|
||
try:
|
||
self.client = MoySkladClient(
|
||
login, password, settings.moysklad_api_url
|
||
)
|
||
# Тестовый запрос
|
||
self.client.get_orders(limit=1)
|
||
self.update_status(True)
|
||
QMessageBox.information(
|
||
self, "Успех", "Авторизация успешна!"
|
||
)
|
||
except Exception as e:
|
||
QMessageBox.critical(
|
||
self, "Ошибка", f"Ошибка авторизации:\n{e}"
|
||
)
|
||
self.update_status(False)
|
||
|
||
def logout(self):
|
||
"""Выход"""
|
||
self.client = None
|
||
self.update_status(False)
|
||
self.current_orders = []
|
||
self.orders_table.setRowCount(0)
|
||
self.search_results_table.setRowCount(0)
|
||
|
||
def update_status(self, logged_in: bool):
|
||
"""Обновление статуса авторизации"""
|
||
if logged_in:
|
||
self.status_label.setText("✓ Авторизован")
|
||
self.status_label.setStyleSheet(
|
||
"color: green; font-weight: bold;"
|
||
)
|
||
self.login_btn.setEnabled(False)
|
||
self.logout_btn.setEnabled(True)
|
||
self.tabs.setEnabled(True)
|
||
else:
|
||
self.status_label.setText("✗ Не авторизован")
|
||
self.status_label.setStyleSheet("color: red; font-weight: bold;")
|
||
self.login_btn.setEnabled(True)
|
||
self.logout_btn.setEnabled(False)
|
||
self.tabs.setEnabled(False)
|
||
|
||
def search_orders(self):
|
||
"""Поиск отгрузок"""
|
||
if not self.client:
|
||
QMessageBox.warning(self, "Ошибка", "Необходима авторизация")
|
||
return
|
||
|
||
query = self.search_input.text().strip()
|
||
if not query:
|
||
QMessageBox.warning(self, "Ошибка", "Введите номер отгрузки")
|
||
return
|
||
|
||
# Блокируем кнопку на время поиска
|
||
self.search_btn.setEnabled(False)
|
||
self.show_progress("Поиск...")
|
||
|
||
def search():
|
||
try:
|
||
return self.client.search_orders(query, limit=10)
|
||
except Exception as e:
|
||
raise Exception(f"Ошибка поиска: {str(e)}")
|
||
|
||
self.run_in_thread(search, self.on_search_complete)
|
||
|
||
def on_search_complete(self, result):
|
||
"""Обработка результатов поиска"""
|
||
self.hide_progress()
|
||
self.search_btn.setEnabled(True)
|
||
|
||
if not result or 'rows' not in result:
|
||
QMessageBox.warning(
|
||
self, "Ошибка", "Не удалось выполнить поиск"
|
||
)
|
||
return
|
||
|
||
orders = result.get('rows', [])
|
||
if not orders:
|
||
QMessageBox.information(
|
||
self, "Результаты", "Отгрузки не найдены"
|
||
)
|
||
return
|
||
|
||
self.display_orders_in_table(orders, self.search_results_table)
|
||
|
||
def load_orders_list(self):
|
||
"""Загрузка списка отгрузок"""
|
||
if not self.client:
|
||
QMessageBox.warning(self, "Ошибка", "Необходима авторизация")
|
||
return
|
||
|
||
# Сброс на первую страницу
|
||
self.current_page = 0
|
||
self.load_page()
|
||
|
||
def load_page(self):
|
||
"""Загрузка текущей страницы"""
|
||
if not self.client:
|
||
return
|
||
|
||
# Блокируем кнопки на время загрузки
|
||
self.load_list_btn.setEnabled(False)
|
||
self.prev_page_btn.setEnabled(False)
|
||
self.next_page_btn.setEnabled(False)
|
||
|
||
offset = self.current_page * self.page_size
|
||
self.show_progress(
|
||
f"Загрузка страницы {self.current_page + 1}..."
|
||
)
|
||
|
||
def load():
|
||
try:
|
||
result = self.client.get_orders(
|
||
limit=self.page_size,
|
||
offset=offset
|
||
)
|
||
# Получаем общее количество
|
||
total = result.get('meta', {}).get('size', 0)
|
||
return result, total
|
||
except Exception as e:
|
||
raise Exception(f"Ошибка загрузки списка: {str(e)}")
|
||
|
||
self.run_in_thread(load, self.on_list_complete)
|
||
|
||
def on_list_complete(self, result):
|
||
"""Обработка списка отгрузок"""
|
||
self.hide_progress()
|
||
self.load_list_btn.setEnabled(True)
|
||
|
||
if not result:
|
||
QMessageBox.warning(
|
||
self, "Ошибка", "Не удалось загрузить список отгрузок"
|
||
)
|
||
return
|
||
|
||
# result теперь кортеж (data, total)
|
||
data, total = result
|
||
|
||
if not data or 'rows' not in data:
|
||
QMessageBox.warning(
|
||
self, "Ошибка", "Не удалось загрузить список отгрузок"
|
||
)
|
||
return
|
||
|
||
self.current_orders = data.get('rows', [])
|
||
self.total_orders = total
|
||
|
||
# Обновляем кнопки пагинации
|
||
self.update_pagination_buttons()
|
||
|
||
# Отображаем отгрузки
|
||
self.display_orders_in_table(self.current_orders, self.orders_table)
|
||
|
||
def update_pagination_buttons(self):
|
||
"""Обновление кнопок пагинации"""
|
||
total_pages = (
|
||
(self.total_orders + self.page_size - 1) // self.page_size
|
||
)
|
||
|
||
# Обновляем текст страницы
|
||
self.page_label.setText(
|
||
f"Страница: {self.current_page + 1} из {total_pages} "
|
||
f"(всего: {self.total_orders})"
|
||
)
|
||
|
||
# Включаем/выключаем кнопки
|
||
self.prev_page_btn.setEnabled(self.current_page > 0)
|
||
self.next_page_btn.setEnabled(
|
||
(self.current_page + 1) * self.page_size < self.total_orders
|
||
)
|
||
|
||
def display_orders_in_table(self, orders: List[Dict], table: QTableWidget):
|
||
"""Отображение отгрузок в таблице"""
|
||
table.setRowCount(len(orders))
|
||
|
||
for i, order in enumerate(orders):
|
||
# Номер
|
||
table.setItem(i, 0, QTableWidgetItem(order.get('name', 'N/A')))
|
||
|
||
# Дата
|
||
date_str = order.get('moment', '')[:10]
|
||
table.setItem(i, 1, QTableWidgetItem(date_str))
|
||
|
||
# Контрагент
|
||
agent = order.get('agent', {})
|
||
table.setItem(i, 2, QTableWidgetItem(agent.get('name', 'N/A')))
|
||
|
||
# Сумма
|
||
sum_val = order.get('sum', 0) / 100
|
||
table.setItem(i, 3, QTableWidgetItem(f"{sum_val:,.2f} ₽"))
|
||
|
||
# Кнопки действий
|
||
actions_widget = QWidget()
|
||
actions_layout = QHBoxLayout()
|
||
actions_layout.setContentsMargins(2, 2, 2, 2)
|
||
|
||
validate_btn = QPushButton("Валидировать")
|
||
validate_btn.clicked.connect(
|
||
lambda checked, o=order: self.validate_order(o)
|
||
)
|
||
actions_layout.addWidget(validate_btn)
|
||
|
||
generate_btn = QPushButton("Генерировать XML")
|
||
generate_btn.clicked.connect(
|
||
lambda checked, o=order: self.generate_xml_for_order(o)
|
||
)
|
||
actions_layout.addWidget(generate_btn)
|
||
|
||
actions_widget.setLayout(actions_layout)
|
||
table.setCellWidget(i, 4, actions_widget)
|
||
|
||
def validate_order(self, order: Dict):
|
||
"""Валидация отгрузки"""
|
||
if not self.client:
|
||
return
|
||
|
||
order_id = order['id']
|
||
self.show_progress("Валидация...")
|
||
|
||
def validate():
|
||
order_data = self.client.get_full_order_data(order_id)
|
||
results = self.validator.validate_order(order_data)
|
||
return order_data, results
|
||
|
||
self.run_in_thread(validate, self.on_validate_complete)
|
||
|
||
def on_validate_complete(self, result):
|
||
"""Обработка результатов валидации"""
|
||
self.hide_progress()
|
||
order_data, validation_results = result
|
||
|
||
level, summary = self.validator.get_validation_summary(
|
||
validation_results
|
||
)
|
||
|
||
if level == ValidationLevel.CRITICAL:
|
||
QMessageBox.critical(self, "Ошибки валидации", summary)
|
||
elif level == ValidationLevel.WARNING:
|
||
QMessageBox.warning(self, "Предупреждения", summary)
|
||
else:
|
||
QMessageBox.information(self, "Валидация", summary)
|
||
|
||
def generate_xml_for_order(self, order: Dict):
|
||
"""Генерация XML для отгрузки"""
|
||
if not self.client:
|
||
return
|
||
|
||
order_id = order['id']
|
||
self.show_progress("Генерация XML...")
|
||
|
||
def generate():
|
||
order_data = self.client.get_full_order_data(order_id)
|
||
xml_content = self.xml_generator.generate_realization(order_data)
|
||
return order_data, xml_content
|
||
|
||
self.run_in_thread(generate, self.on_generate_complete)
|
||
|
||
def on_generate_complete(self, result):
|
||
"""Обработка сгенерированного XML"""
|
||
self.hide_progress()
|
||
order_data, xml_content = result
|
||
|
||
order_name = order_data['order'].get('name', 'order')
|
||
filename, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"Сохранить XML",
|
||
f"order_{order_name}.xml",
|
||
"XML Files (*.xml)"
|
||
)
|
||
|
||
if filename:
|
||
with open(filename, 'w', encoding='utf-8') as f:
|
||
f.write(xml_content)
|
||
QMessageBox.information(
|
||
self, "Успех", f"XML сохранен:\n{filename}"
|
||
)
|
||
|
||
def batch_generate(self):
|
||
"""Массовая генерация"""
|
||
if not self.client:
|
||
QMessageBox.warning(self, "Ошибка", "Необходима авторизация")
|
||
return
|
||
|
||
order_numbers = [
|
||
num.strip()
|
||
for num in self.batch_input.toPlainText().split(',')
|
||
if num.strip()
|
||
]
|
||
|
||
if not order_numbers:
|
||
QMessageBox.warning(
|
||
self, "Ошибка", "Введите номера отгрузок"
|
||
)
|
||
return
|
||
|
||
if len(order_numbers) > 50:
|
||
QMessageBox.warning(
|
||
self, "Ошибка", "Максимум 50 отгрузок за раз"
|
||
)
|
||
return
|
||
|
||
batch_mode = self.batch_mode_combo.currentIndex() == 0
|
||
validate = self.validate_checkbox.isChecked()
|
||
|
||
self.show_progress("Обработка...")
|
||
self.batch_results.clear()
|
||
|
||
def process():
|
||
return self.process_batch(order_numbers, batch_mode, validate)
|
||
|
||
self.run_in_thread(process, self.on_batch_complete)
|
||
|
||
def process_batch(
|
||
self,
|
||
order_numbers: List[str],
|
||
batch_mode: bool,
|
||
validate: bool
|
||
) -> Dict:
|
||
"""Обработка пакета отгрузок"""
|
||
orders_data = []
|
||
not_found = []
|
||
errors = []
|
||
validation_errors = {}
|
||
|
||
for order_num in order_numbers:
|
||
try:
|
||
# Поиск
|
||
search_result = self.client.search_orders(order_num, limit=1)
|
||
rows = search_result.get('rows', [])
|
||
|
||
if not rows:
|
||
not_found.append(order_num)
|
||
continue
|
||
|
||
order_id = rows[0]['id']
|
||
order_data = self.client.get_full_order_data(order_id)
|
||
|
||
# Валидация
|
||
if validate:
|
||
results = self.validator.validate_order(order_data)
|
||
critical = [
|
||
r for r in results
|
||
if r.level == ValidationLevel.CRITICAL
|
||
]
|
||
|
||
if critical:
|
||
validation_errors[order_num] = [
|
||
f"{r.field}: {r.message}" for r in critical
|
||
]
|
||
continue
|
||
|
||
orders_data.append(order_data)
|
||
|
||
except Exception as e:
|
||
errors.append(f"{order_num}: {str(e)[:50]}")
|
||
|
||
return {
|
||
'orders_data': orders_data,
|
||
'not_found': not_found,
|
||
'errors': errors,
|
||
'validation_errors': validation_errors,
|
||
'batch_mode': batch_mode
|
||
}
|
||
|
||
def on_batch_complete(self, result):
|
||
"""Обработка результатов пакетной генерации"""
|
||
self.hide_progress()
|
||
|
||
orders_data = result['orders_data']
|
||
not_found = result['not_found']
|
||
errors = result['errors']
|
||
validation_errors = result['validation_errors']
|
||
batch_mode = result['batch_mode']
|
||
|
||
# Формируем отчет
|
||
report = []
|
||
report.append(f"Обработано: {len(orders_data)} отгрузок")
|
||
|
||
if validation_errors:
|
||
report.append(f"\n❌ Ошибки валидации: {len(validation_errors)}")
|
||
for order_num, errs in validation_errors.items():
|
||
report.append(f"\n {order_num}:")
|
||
for err in errs:
|
||
report.append(f" • {err}")
|
||
|
||
if not_found:
|
||
report.append(f"\n⚠️ Не найдено: {', '.join(not_found)}")
|
||
|
||
if errors:
|
||
report.append("\n⚠️ Ошибки обработки:")
|
||
for err in errors:
|
||
report.append(f" • {err}")
|
||
|
||
self.batch_results.setText('\n'.join(report))
|
||
|
||
# Генерация XML
|
||
if orders_data:
|
||
if batch_mode:
|
||
self.generate_batch_xml(orders_data)
|
||
else:
|
||
self.generate_separate_xmls(orders_data)
|
||
else:
|
||
QMessageBox.warning(
|
||
self,
|
||
"Предупреждение",
|
||
"Нет отгрузок для генерации"
|
||
)
|
||
|
||
def generate_batch_xml(self, orders_data: List[Dict]):
|
||
"""Генерация одного XML со всеми документами"""
|
||
filename, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"Сохранить пакетный XML",
|
||
f"batch_{len(orders_data)}_orders.xml",
|
||
"XML Files (*.xml)"
|
||
)
|
||
|
||
if filename:
|
||
self.xml_generator.generate_batch_realization(
|
||
orders_data, filename
|
||
)
|
||
QMessageBox.information(
|
||
self, "Успех", f"Пакетный XML сохранен:\n{filename}"
|
||
)
|
||
|
||
def generate_separate_xmls(self, orders_data: List[Dict]):
|
||
"""Генерация отдельных XML файлов"""
|
||
directory = QFileDialog.getExistingDirectory(
|
||
self, "Выберите папку для сохранения"
|
||
)
|
||
|
||
if directory:
|
||
for order_data in orders_data:
|
||
order_name = order_data['order'].get('name', 'order')
|
||
filename = os.path.join(
|
||
directory, f"order_{order_name}.xml"
|
||
)
|
||
xml_content = self.xml_generator.generate_realization(
|
||
order_data
|
||
)
|
||
with open(filename, 'w', encoding='utf-8') as f:
|
||
f.write(xml_content)
|
||
|
||
QMessageBox.information(
|
||
self,
|
||
"Успех",
|
||
f"Сохранено {len(orders_data)} файлов в:\n{directory}"
|
||
)
|
||
|
||
def run_in_thread(self, func, callback):
|
||
"""Запуск функции в отдельном потоке"""
|
||
# Останавливаем предыдущий поток если есть
|
||
if self.worker_thread and self.worker_thread.isRunning():
|
||
self.worker_thread.wait()
|
||
|
||
self.worker_thread = WorkerThread(func)
|
||
self.worker_thread.finished.connect(callback)
|
||
self.worker_thread.error.connect(self.on_thread_error)
|
||
self.worker_thread.start()
|
||
|
||
def on_thread_error(self, error_msg: str):
|
||
"""Обработка ошибки в потоке"""
|
||
self.hide_progress()
|
||
# Разблокируем кнопки
|
||
self.search_btn.setEnabled(True)
|
||
self.load_list_btn.setEnabled(True)
|
||
|
||
QMessageBox.critical(self, "Ошибка", error_msg)
|
||
|
||
def show_progress(self, message: str):
|
||
"""Показ прогресса"""
|
||
self.progress_bar.setVisible(True)
|
||
self.progress_bar.setRange(0, 0) # Неопределенный прогресс
|
||
self.statusBar().showMessage(message)
|
||
|
||
def hide_progress(self):
|
||
"""Скрытие прогресса"""
|
||
self.progress_bar.setVisible(False)
|
||
self.statusBar().clearMessage()
|
||
|
||
def prev_page(self):
|
||
"""Предыдущая страница"""
|
||
if self.current_page > 0:
|
||
self.current_page -= 1
|
||
self.load_page()
|
||
|
||
def next_page(self):
|
||
"""Следующая страница"""
|
||
if (self.current_page + 1) * self.page_size < self.total_orders:
|
||
self.current_page += 1
|
||
self.load_page()
|
||
|
||
|
||
def main():
|
||
"""Запуск приложения"""
|
||
app = QApplication(sys.argv)
|
||
app.setApplicationName("МойСклад - Генератор документов")
|
||
app.setOrganizationName("BUH_bot")
|
||
|
||
# Установка иконки приложения для всех окон
|
||
icon_path = os.path.join(os.path.dirname(__file__), 'app_icon.png')
|
||
if os.path.exists(icon_path):
|
||
app.setWindowIcon(QIcon(icon_path))
|
||
|
||
ThemeManager.apply(app)
|
||
|
||
window = MainWindow()
|
||
window.show()
|
||
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|