mirror of
https://github.com/Horizontal42/warehouse-master.git
synced 2026-11-10 16:02:07 +03:00
PyQt5 desktop app: PDF label generator (single/2×2/4×4 grids) and Google Sheets reassembly tracker for laptop inventory management.
393 lines
16 KiB
Python
393 lines
16 KiB
Python
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
|