initial release

PyQt5 desktop app: PDF label generator (single/2×2/4×4 grids) and
Google Sheets reassembly tracker for laptop inventory management.
This commit is contained in:
Andrew Pimenov 2026-06-11 19:14:31 +03:00
commit 08c87e1c37
16 changed files with 1628 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -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

BIN
Inter.ttf Normal file

Binary file not shown.

21
LICENSE Normal file
View file

@ -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.

59
README.md Normal file
View file

@ -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

59
README.ru.md Normal file
View file

@ -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

39
WarehouseMaster.spec Normal file
View file

@ -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'],
)

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

444
main.py Normal file
View file

@ -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_())

87
printing.py Normal file
View file

@ -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)

6
requirements.txt Normal file
View file

@ -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"

99
sheet_config.example.json Normal file
View file

@ -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
}
}

393
sheet_manager.py Normal file
View file

@ -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

122
strings.py Normal file
View file

@ -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": "Таблицы успешно обновлены",
},
}

38
styles.py Normal file
View file

@ -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; }}
"""

129
widgets.py Normal file
View file

@ -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()

107
workers.py Normal file
View file

@ -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))