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:
parent
2b76317a3f
commit
2ee4c56b3a
|
|
@ -1,7 +1,9 @@
|
||||||
# Materials, Finish, Batch Builds & Shop Inventory — Plan (v2, reconciled)
|
# Materials, Finish, Batch Builds & Shop Inventory — Plan (v2, reconciled)
|
||||||
|
|
||||||
Design plan for four related features, reconciled after a Codex review.
|
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)
|
## What changed in v2 (after Codex review)
|
||||||
- **[v2] Sanding never shrinks the design model.** Part dims = final/intended.
|
- **[v2] Sanding never shrinks the design model.** Part dims = final/intended.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -119,6 +119,9 @@ class MainWindow(QMainWindow):
|
||||||
"build a bookshelf side: two {H} 2x4 uprights {W} apart with {N} shelves of 1x8 between them",
|
"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")]))
|
[("Height", "48 in"), ("Width", "12 in"), ("Shelves", "3")]))
|
||||||
|
|
||||||
|
s = mb.addMenu("&Shop")
|
||||||
|
self._act(s, "Inventory…", self._show_inventory)
|
||||||
|
|
||||||
h = mb.addMenu("&Help")
|
h = mb.addMenu("&Help")
|
||||||
self._act(h, "Commands…", self._show_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 = BomWindow(self.controller, self) # keep a ref so it isn't GC'd
|
||||||
self._bom.show()
|
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):
|
def _show_help(self):
|
||||||
QMessageBox.information(self, "Commands", _HELP)
|
QMessageBox.information(self, "Commands", _HELP)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue