Phase 7: shop Inventory window + stats

- 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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 19:25:28 -03:00
parent 2b76317a3f
commit 2ee4c56b3a
4 changed files with 143 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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