From 2ee4c56b3a4a7867996101563400583f0ae28164 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 19:25:28 -0300 Subject: [PATCH] Phase 7: shop Inventory window + stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gui/inventory_window.py: read-only management view over the event ledger — On-hand / Offcut bin / Build history / Stats tabs (shop-wide), with Refresh. - Wired into the main window: new Shop ▸ Inventory… menu. - Plan doc marked complete (all 7 phases; offcut reuse opt-in toggle, purchase price-book update opt-in). - tests: window renders a populated ledger and an empty one. Co-Authored-By: Claude Opus 4.8 (1M context) --- MATERIALS_INVENTORY_PLAN.md | 4 +- src/woodshop/gui/inventory_window.py | 95 ++++++++++++++++++++++++++++ src/woodshop/gui/main_window.py | 8 +++ tests/test_inventory_window.py | 37 +++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/woodshop/gui/inventory_window.py create mode 100644 tests/test_inventory_window.py diff --git a/MATERIALS_INVENTORY_PLAN.md b/MATERIALS_INVENTORY_PLAN.md index 9268087..e60c6ce 100644 --- a/MATERIALS_INVENTORY_PLAN.md +++ b/MATERIALS_INVENTORY_PLAN.md @@ -1,7 +1,9 @@ # Materials, Finish, Batch Builds & Shop Inventory — Plan (v2, reconciled) Design plan for four related features, reconciled after a Codex review. -Nothing built yet. v2 changes vs v1 are marked **[v2]**. +**STATUS: all 7 phases implemented (193 tests).** v2 changes vs v1 are marked **[v2]**. +Settled the two trailing questions: offcut reuse is **opt-in** (a toggle), and +recording a purchase **opt-in** saves prices to the price book. ## What changed in v2 (after Codex review) - **[v2] Sanding never shrinks the design model.** Part dims = final/intended. diff --git a/src/woodshop/gui/inventory_window.py b/src/woodshop/gui/inventory_window.py new file mode 100644 index 0000000..550efec --- /dev/null +++ b/src/woodshop/gui/inventory_window.py @@ -0,0 +1,95 @@ +"""The shop Inventory window — a read-only management view over the event +ledger (on-hand stock, offcut bin, build history, stats). The day-to-day +workflows (mark purchased / record build / use offcuts) live on the BOM window; +this is where you review and audit. Shop-wide across all projects.""" +from __future__ import annotations + +from PySide6.QtGui import QFont +from PySide6.QtWidgets import (QDialog, QHBoxLayout, QPushButton, QTabWidget, + QTextEdit, QVBoxLayout, QWidget) + +from .. import inventory as inventory_mod + + +class InventoryWindow(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Shop Inventory") + self.resize(640, 520) + self._ledger = inventory_mod.Ledger.load() + + self._tabs = {} + tabw = QTabWidget() + for name in ("On-hand", "Offcut bin", "Build history", "Stats"): + te = QTextEdit(readOnly=True) + te.setFont(QFont("monospace")) + self._tabs[name] = te + tabw.addTab(te, name) + + root = QVBoxLayout(self) + root.addWidget(tabw) + row = QHBoxLayout() + refresh = QPushButton("Refresh") + refresh.clicked.connect(self.reload) + row.addStretch(); row.addWidget(refresh) + root.addLayout(row) + self.reload() + + def reload(self) -> None: + self._ledger = inventory_mod.Ledger.load() + self._tabs["On-hand"].setPlainText(self._on_hand_text()) + self._tabs["Offcut bin"].setPlainText(self._offcuts_text()) + self._tabs["Build history"].setPlainText(self._builds_text()) + self._tabs["Stats"].setPlainText(self._stats_text()) + + def _on_hand_text(self) -> str: + oh = self._ledger.on_hand() + if not oh: + return "ON-HAND STOCK\n\n (empty — Mark purchased on the BOM window to add)" + lines = ["ON-HAND STOCK", ""] + for stock, qty in oh.items(): + unit = "sheet" if stock.startswith("ply-") else "stick" + lines.append(f" {qty:>3} × {stock:<10} ({unit}s)") + return "\n".join(lines) + + def _offcuts_text(self) -> str: + bin_ = self._ledger.offcut_bin() + if not bin_: + return "OFFCUT BIN\n\n (empty — kept offcuts from recorded builds land here)" + lines = ["OFFCUT BIN", ""] + for p in sorted(bin_, key=lambda p: (p.stock, -p.length_in)): + if p.is_sheet: + size = f"{p.length_in:g}\" × {p.width_in:g}\"" + else: + size = f"{p.length_in:g}\"" + src = f" from {p.source_project}" if p.source_project else "" + lines.append(f" {p.stock:<10} {size:<14} [{p.id}]{src}") + return "\n".join(lines) + + def _builds_text(self) -> str: + builds = self._ledger.builds() + if not builds: + return "BUILD HISTORY\n\n (no builds recorded yet)" + lines = ["BUILD HISTORY", ""] + for e in builds: + cost = f" ${e['cost']:,.2f}" if e.get("cost") is not None else "" + date = f" {e['date']}" if e.get("date") else "" + lines.append(f" {e['build_id']:<5} {e['project']:<16} ×{e['units']}{cost}{date}") + return "\n".join(lines) + + def _stats_text(self) -> str: + s = self._ledger.stats() + lines = ["SHOP STATS", "", + f" Total spent ${s['spent']:,.2f}", + f" Builds recorded {s['builds']}", + f" Units built {s['units_built']}", + f" Offcuts kept {s['offcuts_kept']}", + f" Offcuts burned {s['offcuts_burned']}", + f" Offcuts trashed {s['offcuts_trashed']}", + "", " Units built by project:"] + if s["by_project"]: + for proj, n in sorted(s["by_project"].items()): + lines.append(f" {proj:<18} {n}") + else: + lines.append(" (none)") + return "\n".join(lines) diff --git a/src/woodshop/gui/main_window.py b/src/woodshop/gui/main_window.py index 8af83a4..43adfe2 100644 --- a/src/woodshop/gui/main_window.py +++ b/src/woodshop/gui/main_window.py @@ -119,6 +119,9 @@ class MainWindow(QMainWindow): "build a bookshelf side: two {H} 2x4 uprights {W} apart with {N} shelves of 1x8 between them", [("Height", "48 in"), ("Width", "12 in"), ("Shelves", "3")])) + s = mb.addMenu("&Shop") + self._act(s, "Inventory…", self._show_inventory) + h = mb.addMenu("&Help") self._act(h, "Commands…", self._show_help) @@ -184,6 +187,11 @@ class MainWindow(QMainWindow): self._bom = BomWindow(self.controller, self) # keep a ref so it isn't GC'd self._bom.show() + def _show_inventory(self): + from .inventory_window import InventoryWindow + self._inventory = InventoryWindow(self) # keep a ref so it isn't GC'd + self._inventory.show() + def _show_help(self): QMessageBox.information(self, "Commands", _HELP) diff --git a/tests/test_inventory_window.py b/tests/test_inventory_window.py new file mode 100644 index 0000000..84581b7 --- /dev/null +++ b/tests/test_inventory_window.py @@ -0,0 +1,37 @@ +"""Offscreen tests for the Inventory window (read-only management view).""" +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from woodshop import inventory as I # noqa: E402 +from woodshop.gui.inventory_window import InventoryWindow # noqa: E402 + +_app = QApplication.instance() or QApplication([]) + + +def test_window_renders_ledger(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + led = I.Ledger() + led.purchase("2x4", 5, price=3.98) + led.record_build("table", 2, consumed={"2x4": 2}, offcuts=[ + {"stock": "2x4", "length_in": 30, "width_in": 3.5, "is_sheet": False}], + dispositions=["keep"]) + led.save() + w = InventoryWindow() + assert "2x4" in w._tabs["On-hand"].toPlainText() + assert "OFFCUT" in w._tabs["Offcut bin"].toPlainText() + assert "table" in w._tabs["Build history"].toPlainText() + stats = w._tabs["Stats"].toPlainText() + assert "Units built" in stats and "2" in stats + + +def test_empty_ledger_renders(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + w = InventoryWindow() + assert "empty" in w._tabs["On-hand"].toPlainText().lower() + assert "no builds" in w._tabs["Build history"].toPlainText().lower()