mirror of
https://github.com/Horizontal42/warehouse-master.git
synced 2026-11-10 14:52:02 +03:00
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:
commit
08c87e1c37
16 changed files with 1628 additions and 0 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
BIN
Inter.ttf
Normal file
Binary file not shown.
21
LICENSE
Normal file
21
LICENSE
Normal 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
59
README.md
Normal 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
59
README.ru.md
Normal 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
39
WarehouseMaster.spec
Normal 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
BIN
icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
444
main.py
Normal file
444
main.py
Normal 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
87
printing.py
Normal 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
6
requirements.txt
Normal 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
99
sheet_config.example.json
Normal 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
393
sheet_manager.py
Normal 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
122
strings.py
Normal 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
38
styles.py
Normal 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
129
widgets.py
Normal 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
107
workers.py
Normal 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))
|
||||
Loading…
Add table
Reference in a new issue