commit 08c87e1c37978788fed8ff6ce2b7684da4daa8ec Author: Andrew Pimenov Date: Thu Jun 11 19:14:31 2026 +0300 initial release PyQt5 desktop app: PDF label generator (single/2×2/4×4 grids) and Google Sheets reassembly tracker for laptop inventory management. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75973e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +build/ +dist/ +__pycache__/ +*.exe +*.pyc + +# secrets and private config — copy sheet_config.example.json and fill in +sheet_config.json +token.json +credentials.json + +# internal/AI tool files +ТЕст/ +.claude/ +CLAUDE.md +*.md.bak +elegant-sauteeing-axolotl.md +.aider* +bugs.md +*_ai_notes* +llm_context* + +# PyInstaller leftovers +*.spec.bak +LabelGenerator.spec diff --git a/Inter.ttf b/Inter.ttf new file mode 100644 index 0000000..eb1f583 Binary files /dev/null and b/Inter.ttf differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c13f991 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b2471a --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +[Русский](README.ru.md) + +# Warehouse Master + +Desktop utility for printing numbered labels as PDF and updating a Google Sheets inventory when reassembling laptops. + +``` +python main.py +``` + +## Install + +Requirements: Python 3.10+, Windows/macOS/Linux. + +``` +pip install -r requirements.txt +``` + +## What it does + +**Labels tab** — generate a PDF of sequentially numbered labels in three formats: + +| Format | Page size | Labels per page | +|--------|-----------|-----------------| +| Single | 58×40 mm landscape | 1 | +| Grid 2×2 | 58×40 mm | 4 | +| Grid 4×4 | 120×75 mm | 16 | + +Enter one or more number ranges, pick a font size, and click Generate PDF. Enable Auto-print to send directly to a printer. Temporary mode deletes the file when the app closes. + +**Reassembly tab** — record which parts were installed in a laptop. Looks up each part ID in a Google Sheet, marks it as shipped, writes the grade to the laptop row, and recalculates the laptop category (NEW/A/B/C/D). Outputs a ready-to-paste comment and tech operation text. + +## Google Sheets setup + +1. Create a Google Cloud project and enable the Sheets API. +2. Create an OAuth 2.0 Desktop client and download `credentials.json`. +3. Place `credentials.json` next to `main.py` (or in `%APPDATA%/WarehouseMaster/`). +4. Copy `sheet_config.example.json` to `sheet_config.json` and edit it to match your spreadsheet structure (worksheet names, column indices, status values). +5. Open Settings and paste the spreadsheet URL. + +On first run the browser will open for OAuth consent; the token is stored in `%APPDATA%/WarehouseMaster/token.json`. + +## For developers + +Stack: Python 3.10, PyQt5, reportlab, gspread, google-auth. + +``` +# run from source +python main.py + +# build standalone exe (Windows) +pyinstaller WarehouseMaster.spec +``` + +`Inter.ttf` is licensed under the SIL Open Font License 1.1. + +## License + +MIT diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..cb8ed1b --- /dev/null +++ b/README.ru.md @@ -0,0 +1,59 @@ +[English](README.md) + +# Warehouse Master + +Десктопная утилита для печати нумерованных этикеток в PDF и обновления инвентаря Google Sheets при пересборке ноутбуков. + +``` +python main.py +``` + +## Установка + +Требования: Python 3.10+, Windows/macOS/Linux. + +``` +pip install -r requirements.txt +``` + +## Что умеет + +**Вкладка «Labels»** — генерировать PDF с последовательно пронумерованными этикетками в трёх форматах: + +| Формат | Размер страницы | Этикеток на страницу | +|--------|-----------------|----------------------| +| Single | 58×40 мм альбом | 1 | +| Grid 2×2 | 58×40 мм | 4 | +| Grid 4×4 | 120×75 мм | 16 | + +Задайте один или несколько диапазонов номеров, выберите размер шрифта, нажмите Generate PDF. Включите Auto-print для прямой отправки на принтер. Режим Temporary удаляет файл при закрытии программы. + +**Вкладка «Reassembly»** — фиксировать, какие запчасти установлены в ноутбук. По ID ищет каждую запчасть в Google Sheets, отмечает её как отгруженную, записывает грейд в строку ноутбука и пересчитывает категорию ноутбука (NEW/A/B/C/D). Выводит текст для вставки в комментарий и техоперацию. + +## Настройка Google Sheets + +1. Создайте проект в Google Cloud и включите Sheets API. +2. Создайте OAuth 2.0 Desktop-клиент и скачайте `credentials.json`. +3. Положите `credentials.json` рядом с `main.py` (или в `%APPDATA%/WarehouseMaster/`). +4. Скопируйте `sheet_config.example.json` в `sheet_config.json` и отредактируйте под свою таблицу (имена листов, индексы колонок, значения статусов). +5. Откройте Settings и вставьте URL таблицы. + +При первом запуске откроется браузер для подтверждения OAuth; токен сохранится в `%APPDATA%/WarehouseMaster/token.json`. + +## Для разработчиков + +Стек: Python 3.10, PyQt5, reportlab, gspread, google-auth. + +``` +# запуск из исходников +python main.py + +# сборка exe (Windows) +pyinstaller WarehouseMaster.spec +``` + +`Inter.ttf` распространяется под лицензией SIL Open Font License 1.1. + +## Лицензия + +MIT diff --git a/WarehouseMaster.spec b/WarehouseMaster.spec new file mode 100644 index 0000000..629832e --- /dev/null +++ b/WarehouseMaster.spec @@ -0,0 +1,39 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[('Inter.ttf', '.'), ('sheet_config.json', '.')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='WarehouseMaster', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['icon.ico'], +) diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..65b0737 Binary files /dev/null and b/icon.ico differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..24366f3 --- /dev/null +++ b/main.py @@ -0,0 +1,444 @@ +import ctypes +import os +import sys +import tempfile + +from PyQt5.QtCore import Qt, QSettings +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import ( + QAction, QApplication, QCheckBox, QDialog, QFormLayout, + QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, + QLineEdit, QMainWindow, QMessageBox, QPushButton, + QRadioButton, QScrollArea, QSpinBox, QTabWidget, QTextEdit, + QVBoxLayout, QWidget, +) + +from printing import ( + app_data_dir, config_path, get_installed_printers, + open_file, print_file, register_fonts, resource_path, +) +from strings import STRINGS +from styles import build_stylesheet +from widgets import NoScrollComboBox, PreferencesDialog, RangeRowWidget +from workers import DEFAULT_FONT_SIZES, PDFWorker, ReassemblyWorker + + +class LabelApp(QMainWindow): + def __init__(self): + super().__init__() + self.font_name = register_fonts() + self.rows: list[RangeRowWidget] = [] + self.temp_files: list[str] = [] + self.current_type = "single" + self.settings = QSettings("WarehouseMaster", "App") + self.locale = self.settings.value("locale", "en") + self._worker = None + self._r_worker = None + + icon_path = resource_path("icon.ico") + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + if sys.platform == "win32": + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("warehousemaster.app") + + self._create_menu_bar() + self._init_ui() + self._load_state() + + def _s(self, key: str) -> str: + return STRINGS.get(self.locale, STRINGS["en"]).get(key, key) + + def _create_menu_bar(self): + self.settings_menu = self.menuBar().addMenu("") + self.pref_action = QAction("", self) + self.pref_action.triggered.connect(self._open_preferences) + self.settings_menu.addAction(self.pref_action) + + def _init_ui(self): + self.setGeometry(100, 100, 500, 700) + self.tabs = QTabWidget() + self.setCentralWidget(self.tabs) + self.tab_gen = QWidget() + self._init_gen_tab() + self.tabs.addTab(self.tab_gen, "") + self.tab_reassembly = QWidget() + self._init_reassembly_tab() + self.tabs.addTab(self.tab_reassembly, "") + + def _init_gen_tab(self): + layout = QVBoxLayout(self.tab_gen) + layout.setSpacing(15) + + self.gb_type = QGroupBox() + vbox = QVBoxLayout(self.gb_type) + self.rb_single = QRadioButton() + self.rb_2x2 = QRadioButton() + self.rb_4x4 = QRadioButton() + vbox.addWidget(self.rb_single) + vbox.addWidget(self.rb_2x2) + vbox.addWidget(self.rb_4x4) + layout.addWidget(self.gb_type) + + self.gb_cfg = QGroupBox() + vbox_cfg = QVBoxLayout(self.gb_cfg) + row_font = QHBoxLayout() + self.lbl_font_size = QLabel() + row_font.addWidget(self.lbl_font_size) + self.spin_font = QSpinBox() + self.spin_font.setRange(6, 140) + row_font.addWidget(self.spin_font) + vbox_cfg.addLayout(row_font) + self.inp_name = QLineEdit() + vbox_cfg.addWidget(self.inp_name) + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setStyleSheet("color: #555;") + vbox_cfg.addWidget(line) + self.chk_autoprint = QCheckBox() + self.chk_autoprint.toggled.connect(self._toggle_printer) + vbox_cfg.addWidget(self.chk_autoprint) + printer_row = QHBoxLayout() + printer_row.setContentsMargins(10, 0, 0, 0) + self.lbl_printer = QLabel() + self.cb_printers = NoScrollComboBox() + self.cb_printers.addItems(get_installed_printers()) + printer_row.addWidget(self.lbl_printer) + printer_row.addWidget(self.cb_printers) + self.printer_widget = QWidget() + self.printer_widget.setLayout(printer_row) + vbox_cfg.addWidget(self.printer_widget) + self.chk_temp = QCheckBox() + vbox_cfg.addWidget(self.chk_temp) + layout.addWidget(self.gb_cfg) + + self.gb_ranges = QGroupBox() + self.ranges_layout = QVBoxLayout() + ranges_container = QWidget() + ranges_container.setLayout(self.ranges_layout) + self.ranges_layout.setAlignment(Qt.AlignTop) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(ranges_container) + gb_ranges_layout = QVBoxLayout(self.gb_ranges) + gb_ranges_layout.addWidget(scroll) + layout.addWidget(self.gb_ranges) + + footer = QVBoxLayout() + self.lbl_total = QLabel() + self.lbl_total.setAlignment(Qt.AlignRight) + footer.addWidget(self.lbl_total) + self.btn_gen = QPushButton() + self.btn_gen.setObjectName("btn_gen") + self.btn_gen.setFixedHeight(50) + self.btn_gen.clicked.connect(self._start_generation) + footer.addWidget(self.btn_gen) + layout.addLayout(footer) + + self.rb_single.toggled.connect(self._update_type) + self.rb_2x2.toggled.connect(self._update_type) + self.rb_4x4.toggled.connect(self._update_type) + self._add_range_row() + + def _init_reassembly_tab(self): + layout = QVBoxLayout(self.tab_reassembly) + + self.grp_info = QGroupBox() + l_info = QGridLayout(self.grp_info) + self.cb_order_type = NoScrollComboBox() + self.inp_order_num = QLineEdit() + self.inp_laptop_id = QLineEdit() + self.lbl_r_order = QLabel() + self.lbl_r_laptop = QLabel() + l_info.addWidget(self.lbl_r_order, 0, 0) + order_row = QHBoxLayout() + order_row.addWidget(self.cb_order_type) + order_row.addWidget(self.inp_order_num) + l_info.addLayout(order_row, 0, 1) + l_info.addWidget(self.lbl_r_laptop, 1, 0) + l_info.addWidget(self.inp_laptop_id, 1, 1) + layout.addWidget(self.grp_info) + + self.grp_parts_box = QGroupBox() + form = QFormLayout(self.grp_parts_box) + self.inp_parts = QLineEdit() + self.inp_ssd = QLineEdit() + self.inp_akb = QLineEdit() + self.lbl_r_parts = QLabel() + self.lbl_r_ssd = QLabel() + self.lbl_r_akb = QLabel() + form.addRow(self.lbl_r_parts, self.inp_parts) + form.addRow(self.lbl_r_ssd, self.inp_ssd) + form.addRow(self.lbl_r_akb, self.inp_akb) + layout.addWidget(self.grp_parts_box) + + self.btn_execute = QPushButton() + self.btn_execute.setFixedHeight(45) + self.btn_execute.setObjectName("btn_gen") + self.btn_execute.clicked.connect(self._run_reassembly) + layout.addWidget(self.btn_execute) + + self.grp_res = QGroupBox() + l_res = QVBoxLayout(self.grp_res) + self.lbl_r_comment = QLabel() + self.txt_comment = QTextEdit() + self.txt_comment.setMaximumHeight(80) + self.lbl_r_tech = QLabel() + self.txt_tech = QTextEdit() + self.txt_tech.setMaximumHeight(80) + l_res.addWidget(self.lbl_r_comment) + l_res.addWidget(self.txt_comment) + l_res.addWidget(self.lbl_r_tech) + l_res.addWidget(self.txt_tech) + layout.addWidget(self.grp_res) + + # --- i18n --- + + def _retranslate(self): + s = self._s + self.setWindowTitle(s("window_title")) + self.tabs.setTabText(0, s("tab_labels")) + self.tabs.setTabText(1, s("tab_reassembly")) + self.settings_menu.setTitle(s("menu_settings")) + self.pref_action.setText(s("menu_preferences")) + # labels tab + self.gb_type.setTitle(s("grp_label_type")) + self.gb_cfg.setTitle(s("grp_label_settings")) + self.gb_ranges.setTitle(s("grp_ranges")) + self.rb_single.setText(s("rb_single")) + self.rb_2x2.setText(s("rb_2x2")) + self.rb_4x4.setText(s("rb_4x4")) + self.lbl_font_size.setText(s("lbl_font_size")) + self.inp_name.setPlaceholderText(s("ph_label_name")) + self.chk_autoprint.setText(s("chk_autoprint")) + self.lbl_printer.setText(s("lbl_printer")) + self.chk_temp.setText(s("chk_temp")) + self._toggle_printer() + self._recalc_total() + # reassembly tab + self.grp_info.setTitle(s("grp_order_info")) + self.grp_parts_box.setTitle(s("grp_parts")) + self.grp_res.setTitle(s("grp_result")) + self.lbl_r_order.setText(s("lbl_order")) + self.lbl_r_laptop.setText(s("lbl_laptop_id")) + cur = self.cb_order_type.currentIndex() + self.cb_order_type.clear() + self.cb_order_type.addItems(STRINGS.get(self.locale, STRINGS["en"])["order_types"]) + self.cb_order_type.setCurrentIndex(max(cur, 0)) + self.inp_order_num.setPlaceholderText(s("ph_order_num")) + self.inp_laptop_id.setPlaceholderText(s("ph_laptop_id")) + self.lbl_r_parts.setText(s("lbl_parts_field")) + self.inp_parts.setPlaceholderText(s("ph_parts")) + self.lbl_r_ssd.setText(s("lbl_ssd_field")) + self.inp_ssd.setPlaceholderText(s("ph_ssd")) + self.lbl_r_akb.setText(s("lbl_akb_field")) + self.inp_akb.setPlaceholderText(s("ph_akb")) + self.btn_execute.setText(s("btn_apply")) + self.lbl_r_comment.setText(s("lbl_comment")) + self.lbl_r_tech.setText(s("lbl_tech")) + + # --- Labels tab logic --- + + def _toggle_printer(self): + is_auto = self.chk_autoprint.isChecked() + self.printer_widget.setEnabled(is_auto) + self.btn_gen.setText(self._s("btn_print") if is_auto else self._s("btn_generate")) + + def _handle_range_action(self, widget: RangeRowWidget, action: str): + if action == "add": + self._add_range_row(self.ranges_layout.indexOf(widget) + 1) + elif action == "del" and len(self.rows) > 1: + widget.deleteLater() + self.rows.remove(widget) + for r in self.rows: + r.set_delete_enabled(len(self.rows) > 1) + self._recalc_total() + + def _add_range_row(self, index: int = -1): + row = RangeRowWidget(self._handle_range_action, self._recalc_total) + if index == -1: + self.ranges_layout.addWidget(row) + self.rows.append(row) + else: + self.ranges_layout.insertWidget(index, row) + self.rows.insert(index, row) + for r in self.rows: + r.set_delete_enabled(len(self.rows) > 1) + self._apply_theme() + + def _recalc_total(self): + total = sum( + v[1] - v[0] + 1 + for r in self.rows + for v in [r.get_values()] + if v and v[0] <= v[1] + ) + self.lbl_total.setText(self._s("lbl_total").format(n=total)) + + def _update_type(self): + if self.rb_single.isChecked(): + self.current_type = "single" + self.inp_name.setEnabled(True) + elif self.rb_2x2.isChecked(): + self.current_type = "grid_2x2" + self.inp_name.setEnabled(False) + else: + self.current_type = "grid_4x4" + self.inp_name.setEnabled(False) + self.spin_font.setValue(DEFAULT_FONT_SIZES[self.current_type]) + + def _start_generation(self): + raw = [r.get_values() for r in self.rows] + ranges = [v for v in raw if v is not None] + if not ranges: + QMessageBox.warning(self, self._s("err_title"), self._s("err_no_ranges")) + return + invalid = [v for v in ranges if v[0] > v[1]] + if invalid: + msg = self._s("err_start_gt_end").format(a=invalid[0][0], b=invalid[0][1]) + QMessageBox.warning(self, self._s("err_title"), msg) + return + self._save_state() + first, last = ranges[0][0], ranges[-1][1] + safe_name = "".join(c for c in (self.inp_name.text() or "") if c.isalnum()).strip() + filename = f"labels_{first}-{last}_{safe_name}.pdf" if safe_name else f"labels_{first}-{last}.pdf" + if self.chk_temp.isChecked(): + folder = tempfile.gettempdir() + else: + folder = self.settings.value("save_path", os.path.join(os.path.expanduser("~"), "Desktop")) + if not os.path.exists(folder): + folder = tempfile.gettempdir() + filepath = os.path.join(folder, filename) + if self.chk_temp.isChecked(): + self.temp_files.append(filepath) + self.btn_gen.setEnabled(False) + self.btn_gen.setText(self._s("btn_generating")) + self._worker = PDFWorker( + ranges, self.current_type, self.inp_name.text(), + self.spin_font.value(), self.font_name, filepath, + ) + self._worker.finished.connect(self._on_pdf_done) + self._worker.error_occurred.connect(self._on_pdf_error) + self._worker.start() + + def _on_pdf_done(self, filepath: str): + self.btn_gen.setEnabled(True) + self._toggle_printer() + if self.chk_autoprint.isChecked(): + print_file(filepath, self.cb_printers.currentText()) + else: + open_file(filepath) + + def _on_pdf_error(self, err: str): + self.btn_gen.setEnabled(True) + self._toggle_printer() + QMessageBox.critical(self, self._s("err_title"), err) + + # --- Reassembly tab logic --- + + def _run_reassembly(self): + url = self.settings.value("sheet_url", "") + if not url: + QMessageBox.warning(self, self._s("err_title"), self._s("err_no_sheet_url")) + return + order_num = self.inp_order_num.text() + lap_id = self.inp_laptop_id.text() + if not order_num or not lap_id: + QMessageBox.warning(self, self._s("err_title"), self._s("err_no_order")) + return + parts_str = self.inp_parts.text() + ssd_id = self.inp_ssd.text() + akb_id = self.inp_akb.text() + if not parts_str and not ssd_id and not akb_id: + QMessageBox.warning(self, self._s("err_title"), self._s("err_no_parts")) + return + self.btn_execute.setEnabled(False) + self.btn_execute.setText(self._s("btn_applying")) + self._r_worker = ReassemblyWorker( + url, config_path(), + self.cb_order_type.currentText(), order_num, + lap_id, parts_str, ssd_id, akb_id, + ) + self._r_worker.finished_signal.connect(self._on_reassembly_done) + self._r_worker.error_signal.connect(self._on_reassembly_error) + self._r_worker.start() + + def _on_reassembly_done(self, comment: str, tech_op: str): + self.btn_execute.setEnabled(True) + self.btn_execute.setText(self._s("btn_apply")) + self.txt_comment.setText(comment) + self.txt_tech.setText(tech_op) + QMessageBox.information(self, self._s("msg_done_title"), self._s("msg_done")) + self._save_state() + self.inp_parts.clear() + self.inp_ssd.clear() + self.inp_akb.clear() + self.inp_laptop_id.setFocus() + + def _on_reassembly_error(self, err: str): + self.btn_execute.setEnabled(True) + self.btn_execute.setText(self._s("btn_apply")) + QMessageBox.critical(self, self._s("err_title"), err) + + # --- State persistence --- + + def _load_state(self): + self.locale = self.settings.value("locale", "en") + t = self.settings.value("type", "single") + if t == "grid_2x2": + self.rb_2x2.setChecked(True) + elif t == "grid_4x4": + self.rb_4x4.setChecked(True) + else: + self.rb_single.setChecked(True) + self.inp_name.setText(self.settings.value("name", "")) + self.chk_temp.setChecked(self.settings.value("temp_mode", False, type=bool)) + self.chk_autoprint.setChecked(self.settings.value("auto_print", False, type=bool)) + printer = self.settings.value("selected_printer", "") + if printer: + idx = self.cb_printers.findText(printer) + if idx >= 0: + self.cb_printers.setCurrentIndex(idx) + self._update_type() + self._apply_theme() + self._retranslate() + + def _save_state(self): + if self.rb_2x2.isChecked(): + t = "grid_2x2" + elif self.rb_4x4.isChecked(): + t = "grid_4x4" + else: + t = "single" + self.settings.setValue("type", t) + self.settings.setValue("name", self.inp_name.text()) + self.settings.setValue("temp_mode", self.chk_temp.isChecked()) + self.settings.setValue("auto_print", self.chk_autoprint.isChecked()) + self.settings.setValue("selected_printer", self.cb_printers.currentText()) + + def _open_preferences(self): + dlg = PreferencesDialog(self) + if dlg.exec_() == QDialog.Accepted: + self.locale = self.settings.value("locale", "en") + self._apply_theme() + self._retranslate() + + def _apply_theme(self): + dark = self.settings.value("theme_idx", 0, type=int) == 0 + self.setStyleSheet(build_stylesheet(dark)) + + def closeEvent(self, event): + for f in self.temp_files: + try: + os.remove(f) + except OSError: + pass + self._save_state() + event.accept() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setStyle("Fusion") + window = LabelApp() + window.show() + sys.exit(app.exec_()) diff --git a/printing.py b/printing.py new file mode 100644 index 0000000..06186ee --- /dev/null +++ b/printing.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import os +import platform +import subprocess +import sys + +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + +HAS_WIN32 = False +try: + if platform.system() == "Windows": + import win32print + import win32api + HAS_WIN32 = True +except ImportError: + pass + + +def resource_path(relative_path: str) -> str: + try: + base = sys._MEIPASS # type: ignore[attr-defined] + except AttributeError: + base = os.path.abspath(".") + return os.path.join(base, relative_path) + + +def app_data_dir() -> str: + base = os.environ.get("APPDATA", os.path.expanduser("~")) + path = os.path.join(base, "WarehouseMaster") + os.makedirs(path, exist_ok=True) + return path + + +def config_path() -> str: + local = resource_path("sheet_config.json") + if os.path.exists(local): + return local + return os.path.join(app_data_dir(), "sheet_config.json") + + +def register_fonts() -> str: + font_path = resource_path("Inter.ttf") + try: + if os.path.exists(font_path): + pdfmetrics.registerFont(TTFont("Inter", font_path)) + return "Inter" + except Exception: + pass + return "Helvetica" + + +def get_installed_printers() -> list[str]: + if not HAS_WIN32: + return [] + try: + flags = win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS + return [p[2] for p in win32print.EnumPrinters(flags)] + except OSError: + return [] + + +def open_file(filepath: str) -> None: + try: + system = platform.system() + if system == "Windows": + os.startfile(filepath) + elif system == "Darwin": + subprocess.call(["open", filepath]) + else: + subprocess.call(["xdg-open", filepath]) + except OSError: + pass + + +def print_file(filepath: str, printer_name: str | None = None) -> None: + try: + if platform.system() == "Windows": + if printer_name and HAS_WIN32: + win32api.ShellExecute(0, "printto", filepath, f'"{printer_name}"', ".", 0) + else: + os.startfile(filepath, "print") + else: + subprocess.call(["lp", filepath]) + except OSError: + open_file(filepath) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..edc3c4e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyQt5>=5.15 +reportlab>=4.0 +gspread>=5.0 +google-auth>=2.0 +google-auth-oauthlib>=1.0 +pywin32>=305; sys_platform == "win32" diff --git a/sheet_config.example.json b/sheet_config.example.json new file mode 100644 index 0000000..60900ac --- /dev/null +++ b/sheet_config.example.json @@ -0,0 +1,99 @@ +{ + "worksheets": { + "laptops": "Ноутбуки шиндовс", + "parts": "Запчасти", + "ssd": "SSD/HDD", + "batteries": "АКБ" + }, + "laptop_columns": { + "serial": 21, + "warehouse": 14, + "status": 15, + "category": 18, + "ssd": 36, + "battery_cycles": 37, + "battery_capacity": 38, + "parts": { + "A": [55], + "A1": [55], + "B": [63], + "B2": [63], + "B1": [71], + "C1": [79, 105], + "C2": [88], + "D": [96], + "Клавиатура": [105], + "Кнопки": [113], + "Сенсор": [119], + "Дисплей": [126] + } + }, + "clear_ranges": { + "A": "BE:BJ", + "A1": "BE:BJ", + "B": "BM:BR", + "B2": "BM:BR", + "B1": "BU:BZ", + "C1": "CC:CI", + "C2": "CL:CQ", + "D": "CT:CZ", + "Клавиатура": "DC:DH", + "Кнопки": "DK:DN", + "Сенсор": "DQ:DU", + "Дисплей": "DX:EF" + }, + "type_translator": { + "Кнопки трекпада": "Кнопки", + "Сенсор трекпада": "Сенсор", + "Дисплей": "Дисплей", + "Клавиатура": "Клавиатура", + "Веб-камера": "Веб-камера", + "A": "A", + "A1": "A1", + "B": "B", + "B1": "B1", + "B2": "B2", + "D": "D", + "C1 (с клавой)": "C1", + "C2 (без клавы)": "C2", + "B - (IR)": "B", + "B - (IR+Win Hello)": "B", + "B2 - (IR)": "B2" + }, + "sort_order": { + "A": 1, + "A1": 2, + "B": 3, + "B2": 4, + "B1": 5, + "C1": 6, + "C2": 7, + "D": 8, + "SSD": 200, + "АКБ": 201 + }, + "statuses": { + "shipped": "Отгружен", + "in_stock_no": "Нет", + "warehouse_value": "Производство", + "assembled_status": "Собран, ждет пресейл" + }, + "part_columns": { + "parts_availability": "B", + "parts_status": "K", + "ssd_availability": "B", + "ssd_status": "E", + "battery_availability": "B", + "battery_status": "J" + }, + "parts_data_range": "G:K", + "parts_data_fields": { "type": 0, "grade": 2, "code": 3 }, + "ssd_data_range": "G:N", + "ssd_data_fields": { "code": 0, "hours": 6, "cycles": 7 }, + "battery_data_range": "I:L", + "battery_data_fields":{ "code": 0, "cycles": 2, "capacity": 3 }, + "ssd_grade_thresholds": { + "a_max_hours": 1000, + "b_max_hours": 15000 + } +} diff --git a/sheet_manager.py b/sheet_manager.py new file mode 100644 index 0000000..be90802 --- /dev/null +++ b/sheet_manager.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import json +import os + +import gspread +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow + +from printing import app_data_dir, resource_path + +SCOPES = ["https://www.googleapis.com/auth/spreadsheets"] + +_REQUIRED_CONFIG_KEYS = { + "worksheets", "laptop_columns", "clear_ranges", + "type_translator", "sort_order", "statuses", "part_columns", +} + +# Cyrillic homoglyphs that look like Latin grade letters +_GRADE_NORM = str.maketrans("АВСД", "ABCD") + + +def _token_path() -> str: + return os.path.join(app_data_dir(), "token.json") + + +def _find_credentials() -> str: + local = resource_path("credentials.json") + if os.path.exists(local): + return local + appdata = os.path.join(app_data_dir(), "credentials.json") + if os.path.exists(appdata): + return appdata + raise FileNotFoundError( + "credentials.json not found next to the app or in %APPDATA%/WarehouseMaster/" + ) + + +class SheetManager: + def __init__(self, sheet_url: str, config_path: str = "sheet_config.json"): + self.sheet_url = sheet_url + self.cfg = self._load_config(config_path) + self.client = None + self.doc = None + self._authorize() + + def _load_config(self, path: str) -> dict: + if not os.path.exists(path): + raise FileNotFoundError(f"Sheet config not found: {path}") + with open(path, encoding="utf-8") as f: + cfg = json.load(f) + missing = _REQUIRED_CONFIG_KEYS - set(cfg) + if missing: + raise ValueError(f"sheet_config.json missing keys: {missing}") + return cfg + + def _authorize(self) -> None: + token = _token_path() + creds = None + if os.path.exists(token): + try: + creds = Credentials.from_authorized_user_file(token, SCOPES) + except Exception: + os.remove(token) + creds = None + if creds and creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + except Exception: + os.remove(token) + creds = None + if not creds or not creds.valid: + creds_file = _find_credentials() + flow = InstalledAppFlow.from_client_secrets_file(creds_file, SCOPES) + creds = flow.run_local_server(port=0) + with open(token, "w") as f: + f.write(creds.to_json()) + self.client = gspread.authorize(creds) + self.doc = self.client.open_by_url(self.sheet_url) + + def _get_rank(self, grade_str: str) -> int: + gs = str(grade_str).strip().upper().translate(_GRADE_NORM) + if "NEW" in gs: + return 5 + if "A" in gs: + return 4 + if "B" in gs: + return 3 + if "C" in gs: + return 2 + if "D" in gs: + return 1 + return 5 + + def _calculate_category(self, laptop_data: list) -> str: + col = self.cfg["laptop_columns"] + parts_cols = col["parts"] + + def part_rank(key: str) -> int: + cols = parts_cols.get(key) + if not cols: + return 5 + c = cols[0] + if c - 1 >= len(laptop_data): + return 5 + val = laptop_data[c - 1] + if key == "SSD": + return 1 if not val else self._get_rank(str(val).split("/")[0]) + return 5 if not val else self._get_rank(val) + + grade_keys = ["A", "B", "B1", "C1", "C2", "D", "Клавиатура", "Кнопки", "Сенсор", "Дисплей", "SSD"] + parts = {k: part_rank(k) for k in grade_keys} + + try: + cyc_raw = laptop_data[col["battery_cycles"] - 1] + cycles = int(str(cyc_raw).strip()) if cyc_raw else 9999 + except (ValueError, IndexError): + cycles = 9999 + + if any(r <= 1 for r in parts.values()): + return "D" + + is_new = ( + parts["B1"] == 5 + and parts["Клавиатура"] == 5 + and parts["Дисплей"] == 5 + and all(v >= 4 for k, v in parts.items() if k not in ("B1", "Клавиатура", "Дисплей")) + and cycles < 150 + ) + if is_new: + return "NEW" + + if parts["B"] >= 3 and all(v >= 4 for k, v in parts.items() if k != "B"): + return "A" + + if ( + parts["D"] >= 2 + and parts["SSD"] >= 2 + and all(v >= 3 for k, v in parts.items() if k not in ("D", "SSD")) + ): + return "B" + + return "C" if all(v >= 2 for v in parts.values()) else "D" + + def _fetch_initial_data(self) -> tuple[list, list, list, list, list]: + ws = self.cfg["worksheets"] + parts_range = self.cfg.get("parts_data_range", "G:K") + fetched = self.doc.values_batch_get([ + f"{ws['laptops']}!A:A", + f"{ws['parts']}!A:A", + f"{ws['ssd']}!A:A", + f"{ws['batteries']}!A:A", + f"{ws['parts']}!{parts_range}", + ]) + vr = fetched["valueRanges"] + + def ids(i): + return [row[0] if row else "" for row in vr[i].get("values", [])] + + return ids(0), ids(1), ids(2), ids(3), vr[4].get("values", []) + + def _load_laptop_row(self, ws_laptops, laptop_row: int) -> list: + col = self.cfg["laptop_columns"] + all_indices = ( + [col["serial"], col["warehouse"], col["status"], col["category"], + col["ssd"], col["battery_cycles"], col["battery_capacity"]] + + [c for cols in col["parts"].values() for c in cols] + ) + max_col = max(all_indices) + end_col = gspread.utils.rowcol_to_a1(1, max_col).rstrip("0123456789") + data = ws_laptops.get(f"A{laptop_row}:{end_col}{laptop_row}")[0] + while len(data) < max_col: + data.append("") + return data + + def _process_ssd(self, ssd_id: str, ids_ssd: list, ws_ssd, laptop_row: int, + laptop_data: list, batches: dict, collected: list, errors: list) -> None: + try: + idx = ids_ssd.index(str(ssd_id)) + row = idx + 1 + pc = self.cfg["part_columns"] + st = self.cfg["statuses"] + fields = self.cfg.get("ssd_data_fields", {"code": 0, "hours": 6, "cycles": 7}) + sc, ec = self.cfg.get("ssd_data_range", "G:N").split(":") + + batches["ssd"].append({"range": f"{pc['ssd_availability']}{row}", "values": [[st["in_stock_no"]]]}) + batches["ssd"].append({"range": f"{pc['ssd_status']}{row}", "values": [[st["shipped"]]]}) + + data = ws_ssd.get(f"{sc}{row}:{ec}{row}")[0] + code = data[fields["code"]] if fields["code"] < len(data) else "?" + hours = data[fields["hours"]] if fields["hours"] < len(data) else "?" + cycles = data[fields["cycles"]] if fields["cycles"] < len(data) else "?" + + thr = self.cfg.get("ssd_grade_thresholds", {"a_max_hours": 1000, "b_max_hours": 15000}) + try: + h = int(str(hours).replace("ч", "").strip()) + sg = "A" if h < thr["a_max_hours"] else ("B" if h < thr["b_max_hours"] else "C") + except (ValueError, TypeError): + sg = "B" + + info = f"{sg}/{hours}/{cycles}" + c_idx = self.cfg["laptop_columns"]["ssd"] + old_val = laptop_data[c_idx - 1] + batches["laptops"].append({ + "range": gspread.utils.rowcol_to_a1(laptop_row, c_idx), + "values": [[info]], + }) + laptop_data[c_idx - 1] = info + + collected.append({ + "type": "SSD", + "report": f"SSD {ssd_id} ({info})", + "tech": f"SSD: Code {code}, ID {ssd_id}, {sg} ({hours}h) (was: {old_val})", + }) + except ValueError: + errors.append(f"SSD {ssd_id} not found") + + def _process_battery(self, akb_id: str, ids_akb: list, ws_akb, laptop_row: int, + laptop_data: list, batches: dict, collected: list, errors: list) -> None: + try: + idx = ids_akb.index(str(akb_id)) + row = idx + 1 + pc = self.cfg["part_columns"] + st = self.cfg["statuses"] + fields = self.cfg.get("battery_data_fields", {"code": 0, "cycles": 2, "capacity": 3}) + sc, ec = self.cfg.get("battery_data_range", "I:L").split(":") + + batches["batteries"].append({"range": f"{pc['battery_availability']}{row}", "values": [[st["in_stock_no"]]]}) + batches["batteries"].append({"range": f"{pc['battery_status']}{row}", "values": [[st["shipped"]]]}) + + data = ws_akb.get(f"{sc}{row}:{ec}{row}")[0] + code = data[fields["code"]] if fields["code"] < len(data) else "?" + cyc = data[fields["cycles"]] if fields["cycles"] < len(data) else "?" + cap = data[fields["capacity"]] if fields["capacity"] < len(data) else "?" + + col = self.cfg["laptop_columns"] + old_cyc = laptop_data[col["battery_cycles"] - 1] + batches["laptops"].append({ + "range": gspread.utils.rowcol_to_a1(laptop_row, col["battery_cycles"]), + "values": [[cyc]], + }) + batches["laptops"].append({ + "range": gspread.utils.rowcol_to_a1(laptop_row, col["battery_capacity"]), + "values": [[cap]], + }) + laptop_data[col["battery_cycles"] - 1] = cyc + laptop_data[col["battery_capacity"] - 1] = cap + + collected.append({ + "type": "АКБ", + "report": f"Battery {akb_id} ({cyc} cycles)", + "tech": f"Battery: Code {code}, ID {akb_id}, {cyc} cycles (was: {old_cyc})", + }) + except ValueError: + errors.append(f"Battery {akb_id} not found") + + def _process_part(self, p_id: str, ids_parts: list, parts_data: list, ws_parts, + laptop_row: int, laptop_data: list, batches: dict, + clear_ranges: list, collected: list, errors: list) -> None: + try: + idx = ids_parts.index(str(p_id)) + row = idx + 1 + pc = self.cfg["part_columns"] + st = self.cfg["statuses"] + fields = self.cfg.get("parts_data_fields", {"type": 0, "grade": 2, "code": 3}) + + if idx < len(parts_data): + raw_row = parts_data[idx] + else: + sc, ec = self.cfg.get("parts_data_range", "G:K").split(":") + raw_row = ws_parts.get(f"{sc}{row}:{ec}{row}")[0] + + raw_type = raw_row[fields["type"]] if fields["type"] < len(raw_row) else "?" + grade = raw_row[fields["grade"]] if fields["grade"] < len(raw_row) else "?" + code = raw_row[fields["code"]] if fields["code"] < len(raw_row) else "?" + + batches["parts"].append({"range": f"{pc['parts_availability']}{row}", "values": [[st["in_stock_no"]]]}) + batches["parts"].append({"range": f"{pc['parts_status']}{row}", "values": [[st["shipped"]]]}) + + internal_type = self.cfg["type_translator"].get(raw_type) + if not internal_type: + for key, val in self.cfg["type_translator"].items(): + if key in raw_type: + internal_type = val + break + + display_type = internal_type or raw_type + old_val = "?" + + if internal_type: + target_cols = self.cfg["laptop_columns"]["parts"].get(internal_type, []) + for c_idx in target_cols: + old_val = laptop_data[c_idx - 1] + batches["laptops"].append({ + "range": gspread.utils.rowcol_to_a1(laptop_row, c_idx), + "values": [[grade]], + }) + laptop_data[c_idx - 1] = grade + + clear_rng = self.cfg["clear_ranges"].get(internal_type) + if clear_rng: + sc, ec = clear_rng.split(":") + clear_ranges.append(f"{sc}{laptop_row}:{ec}{laptop_row}") + + collected.append({ + "type": display_type, + "report": f"{display_type} {p_id}", + "tech": f"{display_type}: Code {code}, ID {p_id}, {grade} (was: {old_val})", + }) + except ValueError: + errors.append(f"Part {p_id} not found") + except Exception as e: + errors.append(f"Error processing part {p_id}: {e}") + + def _write_batches(self, ws: dict, batches: dict, clear_ranges: list) -> None: + if batches["parts"]: + ws["parts"].batch_update(batches["parts"]) + if batches["ssd"]: + ws["ssd"].batch_update(batches["ssd"]) + if batches["batteries"]: + ws["batteries"].batch_update(batches["batteries"]) + if batches["laptops"]: + ws["laptops"].batch_update(batches["laptops"]) + if clear_ranges: + ws["laptops"].batch_clear(clear_ranges) + + def process_reassembly( + self, laptop_id: str, parts_str: str, ssd_id: str, akb_id: str + ) -> tuple[str, list[str], list[str]]: + ids_laptops, ids_parts, ids_ssd, ids_akb, parts_data = self._fetch_initial_data() + + try: + laptop_row = ids_laptops.index(str(laptop_id)) + 1 + except ValueError: + raise ValueError(f"Laptop {laptop_id} not found") + + cfg_ws = self.cfg["worksheets"] + ws = { + "laptops": self.doc.worksheet(cfg_ws["laptops"]), + "parts": self.doc.worksheet(cfg_ws["parts"]), + "ssd": self.doc.worksheet(cfg_ws["ssd"]), + "batteries": self.doc.worksheet(cfg_ws["batteries"]), + } + + laptop_data = self._load_laptop_row(ws["laptops"], laptop_row) + serial = laptop_data[self.cfg["laptop_columns"]["serial"] - 1] + + batches: dict[str, list] = {"laptops": [], "parts": [], "ssd": [], "batteries": []} + clear_ranges: list[str] = [] + collected: list[dict] = [] + errors: list[str] = [] + + if ssd_id: + self._process_ssd(ssd_id, ids_ssd, ws["ssd"], laptop_row, laptop_data, batches, collected, errors) + if akb_id: + self._process_battery(akb_id, ids_akb, ws["batteries"], laptop_row, laptop_data, batches, collected, errors) + if parts_str: + p_ids = [x.strip() for x in parts_str.replace(",", " ").split() if x.strip()] + for p_id in p_ids: + self._process_part( + p_id, ids_parts, parts_data, ws["parts"], + laptop_row, laptop_data, batches, clear_ranges, collected, errors, + ) + + if not collected: + raise ValueError("No valid parts found — nothing written") + + new_category = self._calculate_category(laptop_data) + col = self.cfg["laptop_columns"] + st = self.cfg["statuses"] + batches["laptops"].append({ + "range": gspread.utils.rowcol_to_a1(laptop_row, col["warehouse"]), + "values": [[st["warehouse_value"]]], + }) + batches["laptops"].append({ + "range": gspread.utils.rowcol_to_a1(laptop_row, col["status"]), + "values": [[st["assembled_status"]]], + }) + batches["laptops"].append({ + "range": gspread.utils.rowcol_to_a1(laptop_row, col["category"]), + "values": [[new_category]], + }) + + self._write_batches(ws, batches, clear_ranges) + + collected.sort(key=lambda item: (self.cfg["sort_order"].get(item["type"], 100), item["type"])) + report_lines = [item["report"] for item in collected] + tech_lines = [item["tech"] for item in collected] + if errors: + report_lines.append("\n[ERRORS]:\n" + "\n".join(errors)) + + return serial, report_lines, tech_lines diff --git a/strings.py b/strings.py new file mode 100644 index 0000000..80f86f6 --- /dev/null +++ b/strings.py @@ -0,0 +1,122 @@ +STRINGS: dict[str, dict] = { + "en": { + "window_title": "Warehouse Master", + "tab_labels": "Labels", + "tab_reassembly": "Reassembly", + "menu_settings": "Settings", + "menu_preferences": "Preferences...", + # labels tab + "grp_label_type": "1. Label Type", + "grp_label_settings": "2. Label Settings", + "grp_ranges": "3. Number Ranges", + "rb_single": "Single label (58×40 mm)", + "rb_2x2": "Grid 2×2 (58×40 mm)", + "rb_4x4": "Grid 4×4 (120×75 mm)", + "lbl_font_size": "Font size:", + "ph_label_name": "Label name", + "chk_autoprint": "Auto-print", + "lbl_printer": "Printer:", + "chk_temp": "Temporary mode (delete on exit)", + "btn_generate": "Generate PDF", + "btn_print": "Print", + "btn_generating": "Generating...", + "lbl_total": "Total: {n}", + # reassembly tab + "grp_order_info": "Order Info", + "grp_parts": "Parts", + "grp_result": "Result", + "lbl_order": "Order:", + "order_types": ["WO", "RO"], + "ph_order_num": "Number", + "lbl_laptop_id": "Laptop ID:", + "ph_laptop_id": "Laptop ID", + "lbl_parts_field": "Parts (ID):", + "ph_parts": "e.g. 12, 64, 105", + "lbl_ssd_field": "SSD (ID):", + "ph_ssd": "SSD ID", + "lbl_akb_field": "Battery (ID):", + "ph_akb": "Battery ID", + "btn_apply": "Apply changes", + "btn_applying": "Working...", + "lbl_comment": "Comment:", + "lbl_tech": "Tech operation:", + # preferences dialog + "dlg_title": "Settings", + "dlg_theme": "Theme:", + "theme_dark": "Dark", + "theme_light": "Light", + "dlg_language": "Language:", + "dlg_sheet_url": "Google Sheet URL:", + "dlg_save_folder": "Save folder:", + "dlg_select_folder": "Select folder", + "btn_save": "Save", + "btn_cancel": "Cancel", + # messages + "err_title": "Error", + "err_no_ranges": "Enter at least one range", + "err_start_gt_end": "Start > end in range: {a}–{b}", + "err_no_sheet_url": "Set the Google Sheet URL in Settings", + "err_no_order": "Fill in order number and laptop ID", + "err_no_parts": "Add at least one part", + "msg_done_title": "Done", + "msg_done": "Spreadsheet updated successfully", + }, + "ru": { + "window_title": "Warehouse Master", + "tab_labels": "Этикетки", + "tab_reassembly": "Пересборка", + "menu_settings": "Настройки", + "menu_preferences": "Настройки...", + "grp_label_type": "1. Тип этикетки", + "grp_label_settings": "2. Параметры", + "grp_ranges": "3. Диапазоны номеров", + "rb_single": "Одиночная (58×40 мм)", + "rb_2x2": "Сетка 2×2 (58×40 мм)", + "rb_4x4": "Сетка 4×4 (120×75 мм)", + "lbl_font_size": "Размер шрифта:", + "ph_label_name": "Название этикетки", + "chk_autoprint": "Автопечать", + "lbl_printer": "Принтер:", + "chk_temp": "Временный режим (удалить при выходе)", + "btn_generate": "Создать PDF", + "btn_print": "Печать", + "btn_generating": "Генерация...", + "lbl_total": "Итого: {n}", + "grp_order_info": "Заказ", + "grp_parts": "Запчасти", + "grp_result": "Результат", + "lbl_order": "Заказ:", + "order_types": ["ЗП", "ВЗ"], + "ph_order_num": "Номер", + "lbl_laptop_id": "ID ноута:", + "ph_laptop_id": "ID Ноутбука", + "lbl_parts_field": "Запчасти (ID):", + "ph_parts": "Например: 12, 64, 105", + "lbl_ssd_field": "SSD (ID):", + "ph_ssd": "ID SSD", + "lbl_akb_field": "АКБ (ID):", + "ph_akb": "ID АКБ", + "btn_apply": "Внести изменения", + "btn_applying": "Обработка...", + "lbl_comment": "Комментарий:", + "lbl_tech": "Техоперация:", + "dlg_title": "Настройки", + "dlg_theme": "Тема:", + "theme_dark": "Тёмная", + "theme_light": "Светлая", + "dlg_language": "Язык:", + "dlg_sheet_url": "URL таблицы Google:", + "dlg_save_folder": "Папка сохранения:", + "dlg_select_folder": "Выбрать папку", + "btn_save": "Сохранить", + "btn_cancel": "Отмена", + "err_title": "Ошибка", + "err_no_ranges": "Введите хотя бы один диапазон", + "err_start_gt_end": "Начало > конец в диапазоне: {a}–{b}", + "err_no_sheet_url": "Укажите URL таблицы в настройках", + "err_no_order": "Заполните номер заказа и ID ноутбука", + "err_no_parts": "Добавьте хотя бы одну запчасть", + "msg_done_title": "Готово", + "msg_done": "Таблицы успешно обновлены", + }, +} diff --git a/styles.py b/styles.py new file mode 100644 index 0000000..c2c716b --- /dev/null +++ b/styles.py @@ -0,0 +1,38 @@ +def build_stylesheet(dark: bool) -> str: + if dark: + bg, text = "#212529", "#f8f9fa" + inp_bg, inp_border = "#343a40", "#6c757d" + btn_bg, btn_hover = "#343a40", "#495057" + menu_sel, disabled_btn = "#0D6EFD", "#555555" + active_inp, disabled_inp = "#ffffff", "#6c757d" + menu_bg = "#2b2b2b" + else: + bg, text = "#f8f9fa", "#212529" + inp_bg, inp_border = "#ffffff", "#ced4da" + btn_bg, btn_hover = "#e9ecef", "#dee2e6" + menu_sel, disabled_btn = "#0D6EFD", "#cccccc" + active_inp, disabled_inp = "#212529", "#adb5bd" + menu_bg = "#f0f0f0" + + return f""" + QMainWindow, QWidget {{ background-color: {bg}; color: {text}; font-family: 'Segoe UI', sans-serif; }} + QMenuBar {{ background-color: {btn_bg}; color: {text}; }} + QMenuBar::item:selected, QMenu::item:selected {{ background-color: {menu_sel}; color: white; }} + QMenu {{ background-color: {menu_bg}; color: {text}; border: 1px solid {inp_border}; }} + QGroupBox {{ border: 1px solid {inp_border}; border-radius: 6px; margin-top: 12px; padding-top: 10px; font-weight: bold; }} + QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 5px; }} + QLineEdit, QSpinBox, QComboBox, QTextEdit {{ background-color: {inp_bg}; color: {active_inp}; border: 1px solid {inp_border}; padding: 6px; border-radius: 4px; }} + QLineEdit:disabled, QSpinBox:disabled, QComboBox:disabled {{ background-color: {bg}; color: {disabled_inp}; border: 1px dashed {inp_border}; }} + QCheckBox {{ color: {text}; }} + QPushButton {{ background-color: {btn_bg}; border: 1px solid {inp_border}; border-radius: 4px; color: {text}; font-weight: bold; }} + QPushButton:hover {{ background-color: {btn_hover}; }} + QPushButton[text="+"] {{ color: #2ecc71; font-size: 16px; }} + QPushButton[text="–"] {{ color: #e74c3c; font-size: 16px; }} + QPushButton[text="–"]:disabled {{ color: {disabled_btn}; border-color: {disabled_btn}; background-color: transparent; }} + QPushButton#btn_gen {{ background-color: #0d6efd; color: white; border: none; font-size: 14px; }} + QPushButton#btn_gen:hover {{ background-color: #0b5ed7; }} + QScrollArea {{ border: none; }} + QTabWidget::pane {{ border: 1px solid {inp_border}; }} + QTabBar::tab {{ background: {btn_bg}; color: {text}; padding: 10px; border-top-left-radius: 4px; border-top-right-radius: 4px; }} + QTabBar::tab:selected {{ background: {menu_sel}; color: white; }} + """ diff --git a/widgets.py b/widgets.py new file mode 100644 index 0000000..4645d08 --- /dev/null +++ b/widgets.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import os + +from PyQt5.QtCore import QSettings +from PyQt5.QtGui import QIntValidator +from PyQt5.QtWidgets import ( + QComboBox, QDialog, QFileDialog, QFormLayout, + QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, +) + + +class NoScrollComboBox(QComboBox): + def wheelEvent(self, event): + event.ignore() + + +class RangeRowWidget(QWidget): + def __init__(self, on_action, on_change): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.inp_start = QLineEdit() + self.inp_start.setPlaceholderText("Start") + self.inp_start.setValidator(QIntValidator(0, 9_999_999)) + self.inp_start.textChanged.connect(on_change) + + self.inp_end = QLineEdit() + self.inp_end.setPlaceholderText("End") + self.inp_end.setValidator(QIntValidator(0, 9_999_999)) + self.inp_end.textChanged.connect(on_change) + + self.btn_add = QPushButton("+") + self.btn_add.setFixedSize(30, 30) + self.btn_add.clicked.connect(lambda: on_action(self, "add")) + + self.btn_del = QPushButton("–") + self.btn_del.setFixedSize(30, 30) + self.btn_del.clicked.connect(lambda: on_action(self, "del")) + + layout.addWidget(self.inp_start) + layout.addWidget(QLabel("➜")) + layout.addWidget(self.inp_end) + layout.addWidget(self.btn_add) + layout.addWidget(self.btn_del) + + def get_values(self) -> tuple[int, int] | None: + s, e = self.inp_start.text(), self.inp_end.text() + if s and e: + return int(s), int(e) + return None + + def set_delete_enabled(self, enabled: bool) -> None: + self.btn_del.setEnabled(enabled) + + +class PreferencesDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.settings = QSettings("WarehouseMaster", "App") + from strings import STRINGS + locale = self.settings.value("locale", "en") + self._t = STRINGS.get(locale, STRINGS["en"]) + self.setWindowTitle(self._t["dlg_title"]) + self.setFixedSize(450, 290) + self._init_ui() + self._load() + + def _init_ui(self): + t = self._t + layout = QVBoxLayout(self) + form = QFormLayout() + + self.cb_language = NoScrollComboBox() + self.cb_language.addItems(["English", "Русский"]) + form.addRow(t["dlg_language"], self.cb_language) + + self.cb_theme = NoScrollComboBox() + self.cb_theme.addItems([t["theme_dark"], t["theme_light"]]) + form.addRow(t["dlg_theme"], self.cb_theme) + + self.inp_url = QLineEdit() + form.addRow(t["dlg_sheet_url"], self.inp_url) + + path_row = QHBoxLayout() + self.inp_path = QLineEdit() + self.inp_path.setReadOnly(True) + btn_browse = QPushButton("...") + btn_browse.setFixedWidth(40) + btn_browse.clicked.connect(self._browse) + path_row.addWidget(self.inp_path) + path_row.addWidget(btn_browse) + form.addRow(t["dlg_save_folder"], path_row) + + layout.addLayout(form) + + btns = QHBoxLayout() + btn_save = QPushButton(t["btn_save"]) + btn_save.clicked.connect(self._save) + btn_cancel = QPushButton(t["btn_cancel"]) + btn_cancel.clicked.connect(self.reject) + btns.addStretch() + btns.addWidget(btn_cancel) + btns.addWidget(btn_save) + layout.addLayout(btns) + + def _browse(self): + folder = QFileDialog.getExistingDirectory( + self, self._t["dlg_select_folder"], self.inp_path.text() + ) + if folder: + self.inp_path.setText(folder) + + def _load(self): + locale = self.settings.value("locale", "en") + self.cb_language.setCurrentIndex(0 if locale == "en" else 1) + self.cb_theme.setCurrentIndex(self.settings.value("theme_idx", 0, type=int)) + self.inp_path.setText( + self.settings.value("save_path", os.path.join(os.path.expanduser("~"), "Desktop")) + ) + self.inp_url.setText(self.settings.value("sheet_url", "")) + + def _save(self): + self.settings.setValue("locale", "en" if self.cb_language.currentIndex() == 0 else "ru") + self.settings.setValue("theme_idx", self.cb_theme.currentIndex()) + self.settings.setValue("save_path", self.inp_path.text()) + self.settings.setValue("sheet_url", self.inp_url.text()) + self.accept() diff --git a/workers.py b/workers.py new file mode 100644 index 0000000..f999bda --- /dev/null +++ b/workers.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from PyQt5.QtCore import QThread, pyqtSignal +from reportlab.lib.pagesizes import landscape +from reportlab.lib.units import mm +from reportlab.pdfgen import canvas + +PAGE_SIZES: dict[str, tuple[float, float]] = { + "single": (58 * mm, 40 * mm), + "grid_2x2": (58 * mm, 40 * mm), + "grid_4x4": (120 * mm, 75 * mm), +} +DEFAULT_FONT_SIZES: dict[str, int] = {"single": 32, "grid_2x2": 24, "grid_4x4": 36} + + +class PDFWorker(QThread): + finished = pyqtSignal(str) + error_occurred = pyqtSignal(str) + + def __init__(self, ranges, label_type, label_name, font_size, font_name, filepath): + super().__init__() + self.ranges = ranges + self.label_type = label_type + self.label_name = label_name + self.font_size = font_size + self.font_name = font_name + self.filepath = filepath + + def run(self): + try: + if not self.ranges: + raise ValueError("No ranges defined") + pw, ph = PAGE_SIZES[self.label_type] + if self.label_type == "single": + page_size = landscape((pw, ph)) + cols, rows = 1, 1 + elif self.label_type == "grid_2x2": + page_size = (pw, ph) + cols, rows = 2, 2 + else: + page_size = (pw, ph) + cols, rows = 4, 4 + c = canvas.Canvas(self.filepath, pagesize=page_size) + c.setFont(self.font_name, self.font_size) + cell_w = pw / cols + cell_h = ph / rows + items_per_page = cols * rows + idx = 0 + for start, end in self.ranges: + for num in range(start, end + 1): + col = idx % cols + row = idx // cols + if self.label_type == "single": + c.drawCentredString(pw / 2, ph / 2 + 5 * mm, self.label_name) + c.drawCentredString(pw / 2, ph / 2 - 5 * mm, f"№ {num}") + else: + x = col * cell_w + cell_w / 2 + y = ph - (row + 1) * cell_h + cell_h / 2 - self.font_size / 3 + c.drawCentredString(x, y, str(num)) + idx += 1 + if idx >= items_per_page: + c.showPage() + c.setFont(self.font_name, self.font_size) + idx = 0 + if idx > 0: + c.showPage() + c.save() + self.finished.emit(self.filepath) + except Exception as e: + self.error_occurred.emit(str(e)) + + +class ReassemblyWorker(QThread): + finished_signal = pyqtSignal(str, str) + error_signal = pyqtSignal(str) + + def __init__(self, sheet_url, cfg_path, order_prefix, order_num, laptop_id, parts_str, ssd_id, akb_id): + super().__init__() + self.sheet_url = sheet_url + self.cfg_path = cfg_path + self.order_prefix = order_prefix + self.order_num = order_num + self.laptop_id = laptop_id + self.parts_str = parts_str + self.ssd_id = ssd_id + self.akb_id = akb_id + + def run(self): + try: + try: + from sheet_manager import SheetManager + except ImportError as e: + self.error_signal.emit(f"Google Sheets dependencies not installed: {e}") + return + manager = SheetManager(self.sheet_url, self.cfg_path) + serial, report_lines, tech_lines = manager.process_reassembly( + self.laptop_id, self.parts_str, self.ssd_id, self.akb_id + ) + comment = ( + f"{self.order_prefix} {self.order_num}\n" + f"{self.laptop_id} {serial}\n" + + "\n".join(report_lines) + ) + tech_op = f"Parts for {self.laptop_id} (S/N {serial}):\n" + "\n".join(tech_lines) + self.finished_signal.emit(comment, tech_op) + except Exception as e: + self.error_signal.emit(str(e))