Phase 6: inventory workflows (purchase / record build / use offcuts)

Wires the ledger into the BOM window via three workflows (Codex's
workflow-first UX), plus an offcut-consuming planner.

- StockPiece gains owned/source; build_cut_plan(available=) seeds the packer
  from owned offcuts (reusing the seeded-packing) so they're consumed before
  buying. Score reports stock_count = pieces to BUY (offcuts free) + owned_count;
  prices.estimate and the Buy list exclude owned pieces.
- BOM header: "Build units", an opt-in "Use shop offcuts" toggle, and
  "Mark purchased…" / "Record build…" buttons.
- PurchaseDialog: confirm qty + price each, opt-in "save prices to price book".
- RecordBuildDialog: shows consumed stock + each offcut with Keep/Burn/Trash/
  Ignore before committing (the moment to correct reality).
- Ledger.record_build takes per-offcut dispositions; used offcuts are consumed
  from the bin; build cost snapshot logged.
- tests: offcut toggle drops buy-count to 0, record-build writes the ledger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 19:23:24 -03:00
parent 30a10adabc
commit 2b76317a3f
6 changed files with 271 additions and 38 deletions

View File

@ -14,7 +14,7 @@ import hashlib
from dataclasses import asdict, dataclass, field, fields
from .cutlist import cut_length
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood, normalize_stock
_EPS = 1e-6
@ -103,6 +103,8 @@ class StockPiece:
width_in: float
placements: list = field(default_factory=list) # Placement
waste: list = field(default_factory=list) # WasteRegion
owned: bool = False # True = an offcut you already have (not bought)
source: str = "" # offcut id / origin, when owned
@dataclass
@ -137,7 +139,8 @@ class CutPlan:
id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"],
length_in=s["length_in"], width_in=s["width_in"],
placements=[Placement(**p) for p in s.get("placements", [])],
waste=[WasteRegion(**w) for w in s.get("waste", [])])
waste=[WasteRegion(**w) for w in s.get("waste", [])],
owned=s.get("owned", False), source=s.get("source", ""))
return cls(
settings=ShopSettings.from_dict(d.get("settings")),
items=[CutItem(**i) for i in d.get("items", [])],
@ -431,8 +434,21 @@ def _pack_plywood_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
return _guillotine_pack(items, stock, s, ids, seed_sheets)
def _offcut_seeds(available, stock, ids) -> list:
"""StockPieces representing owned offcuts of `stock`, to fill before buying."""
seeds = []
for oc in available or []:
if normalize_stock(getattr(oc, "stock", "")) != stock:
continue
seeds.append(StockPiece(id=ids("oc"), stock=stock, is_sheet=oc.is_sheet,
length_in=oc.length_in, width_in=oc.width_in,
owned=True, source=getattr(oc, "id", "")))
return seeds
def build_cut_plan(scene, settings: ShopSettings | None = None,
strategy: str = "decreasing", quantity: int = 1) -> CutPlan:
strategy: str = "decreasing", quantity: int = 1,
available=None) -> CutPlan:
from dataclasses import replace
s = settings or ShopSettings()
@ -455,9 +471,15 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
stock_pieces, unplaced, warnings = [], [], []
for stock, its in by_stock.items():
seeds = _offcut_seeds(available, stock, ids)
if its[0].is_sheet:
sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine"
else _pack_plywood(its, stock, s, ids))
if seeds:
sps, un = _pack_plywood_seeded(its, stock, s, ids, seeds)
else:
sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine"
else _pack_plywood(its, stock, s, ids))
elif seeds:
sps, un = _pack_lumber_seeded(its, stock, s, ids, seeds)
elif strategy == "exact":
sps, un = _pack_lumber_exact(its, stock, s, ids)
else:
@ -478,14 +500,17 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
def _score(stock_pieces, s, strategy, warnings) -> dict:
waste_area = used_area = bought_area = 0.0
reusable = 0
bought = [sp for sp in stock_pieces if not sp.owned]
for sp in stock_pieces:
used = sum(p.len_in * p.wid_in for p in sp.placements)
used_area += used
if sp.is_sheet:
bought_area += sp.length_in * sp.width_in
if not sp.owned:
bought_area += sp.length_in * sp.width_in
waste_area += sp.length_in * sp.width_in - used
else:
bought_area += sp.length_in * sp.width_in
if not sp.owned:
bought_area += sp.length_in * sp.width_in
for w in sp.waste:
waste_area += w.length_in * (w.width_in or sp.width_in)
if w.reusable:
@ -494,7 +519,8 @@ def _score(stock_pieces, s, strategy, warnings) -> dict:
for w in sp.waste if w.reusable)
return {
"strategy_name": strategy,
"stock_count": len(stock_pieces),
"stock_count": len(bought), # pieces you must BUY (offcuts free)
"owned_count": len(stock_pieces) - len(bought),
"waste_area": round(waste_area, 1),
"reusable_offcuts": reusable,
"reusable_in": round(reusable_in, 1),
@ -617,14 +643,15 @@ STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shu
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24,
quantity: int = 1) -> CutPlan:
quantity: int = 1, available=None) -> CutPlan:
"""Find a better layout by trying several strategies + shuffle restarts and
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
best = None
for st in strategies:
plan = build_cut_plan(scene, settings, strategy=st, quantity=quantity)
plan = build_cut_plan(scene, settings, strategy=st, quantity=quantity,
available=available)
if best is None or _plan_key(plan) < _plan_key(best):
best = plan
if best is not None:

View File

@ -8,12 +8,12 @@ import subprocess
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout,
QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget,
QTextEdit, QVBoxLayout, QWidget)
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QDoubleSpinBox, QFormLayout, QGraphicsItem, QGraphicsRectItem,
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
QHBoxLayout, QHeaderView, QLabel, QMenu, QMessageBox,
QPushButton, QScrollArea, QSpinBox, QTableWidget,
QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from collections import Counter
@ -25,6 +25,8 @@ from ..instructions import build_steps, format_steps, polish_prompt
from ..jigs import explain_prompt, format_jigs, suggest_jigs
from .. import prices as prices_mod
from .. import estimate as estimate_mod
from .. import inventory as inventory_mod
from ..inventory import plan_consumption
from .workers import run_async
_PX = 7.0 # pixels per inch in the layout view
@ -82,6 +84,8 @@ class BomWindow(QDialog):
self._prices = prices_mod.load_prices()
self._rates = estimate_mod.load_rates()
self._ledger = inventory_mod.Ledger.load()
self._use_offcuts = False
self._cut_te = self._mono_te()
self._shop_te = self._mono_te()
tabs = QTabWidget()
@ -92,7 +96,8 @@ class BomWindow(QDialog):
tabs.addTab(self._instructions_tab(), "Instructions")
tabs.addTab(self._jigs_tab(), "Jigs")
# header: build quantity (nests all units together → real per-unit cost)
# header: build quantity (nests all units together → real per-unit cost),
# an opt-in toggle to consume shop offcuts first, and inventory workflows.
header = QHBoxLayout()
header.addWidget(QLabel("Build units:"))
self._qty_spin = QSpinBox()
@ -102,16 +107,83 @@ class BomWindow(QDialog):
"carry across units")
self._qty_spin.valueChanged.connect(self._on_quantity_changed)
header.addWidget(self._qty_spin)
self._offcut_chk = QCheckBox("Use shop offcuts")
self._offcut_chk.setToolTip("Consume offcuts you already have before buying new stock")
self._offcut_chk.toggled.connect(self._on_use_offcuts)
header.addWidget(self._offcut_chk)
header.addStretch()
buy = QPushButton("Mark purchased…")
buy.setToolTip("Add this shopping list to your shop inventory")
buy.clicked.connect(self._mark_purchased)
rec = QPushButton("Record build…")
rec.setToolTip("Deduct stock, keep/discard offcuts, log the build")
rec.clicked.connect(self._record_build)
header.addWidget(buy); header.addWidget(rec)
root = QVBoxLayout(self)
root.addLayout(header)
root.addWidget(tabs)
self._refresh_all()
def _available(self):
if not self._use_offcuts:
return None
return self._ledger.available_stock(self._plan.settings.stick_len_in)
def _rebuild_base(self) -> None:
self._optimized = False
self._set_plan(build_cut_plan(self.c.scene, quantity=self._quantity,
available=self._available()))
def _on_quantity_changed(self, value: int) -> None:
self._quantity = max(1, value)
self._set_plan(build_cut_plan(self.c.scene, quantity=self._quantity))
self._rebuild_base()
def _on_use_offcuts(self, on: bool) -> None:
self._use_offcuts = on
self._rebuild_base()
def _mark_purchased(self) -> None:
bought = Counter(sp.stock for sp in self._plan.stock_pieces
if not getattr(sp, "owned", False))
if not bought:
QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.")
return
dlg = PurchaseDialog(bought, self._prices, self)
if not dlg.exec():
return
rows, save = dlg.result()
for stock, qty, price in rows:
self._ledger.purchase(stock, qty, price=price)
if save:
self._prices[stock] = price
self._ledger.save()
if save:
prices_mod.save_prices(self._prices)
self._cost_te.setPlainText(self._cost_text())
QMessageBox.information(self, "Added to inventory",
f"Added {sum(q for _, q, _ in rows)} item(s) to shop inventory.")
def _record_build(self) -> None:
consumed, offcuts = plan_consumption(self._plan)
used = [sp.source for sp in self._plan.stock_pieces
if getattr(sp, "owned", False) and sp.source]
dlg = RecordBuildDialog(consumed, offcuts, self._quantity, self)
if not dlg.exec():
return
for oid in used:
self._ledger.consume_offcut(oid)
cost = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices,
self._rates, quantity=self._quantity).total_cost
project = getattr(self.c, "scene_path", None)
project = project.stem if project else "project"
self._ledger.record_build(project, self._quantity, consumed, offcuts,
dispositions=dlg.dispositions(), cost=cost)
self._ledger.save()
if self._use_offcuts:
self._rebuild_base()
QMessageBox.information(self, "Build recorded",
"Recorded in shop inventory (File ▸ Inventory to view).")
# ----- one active plan; all tabs render from it ---------------------
def _set_plan(self, plan) -> None:
@ -171,12 +243,16 @@ class BomWindow(QDialog):
def _shop_text(self) -> str:
plan = self._plan
lines = ["SHOPPING LIST", "", "Buy:"]
for stock, qty in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
bought = [sp for sp in plan.stock_pieces if not getattr(sp, "owned", False)]
for stock, qty in sorted(Counter(sp.stock for sp in bought).items()):
s = "s" if qty != 1 else ""
unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')"
lines.append(f" {qty} × {stock} {unit}")
if not plan.stock_pieces:
lines.append(" (nothing yet)")
if not bought:
lines.append(" (nothing to buy)")
owned = [sp for sp in plan.stock_pieces if getattr(sp, "owned", False)]
if owned:
lines += ["", f"Using {len(owned)} offcut(s) from your shop inventory."]
if plan.unplaced:
lines += ["", "⚠ Won't fit standard stock — source / cut specially:"]
for iid in plan.unplaced:
@ -701,3 +777,81 @@ class RatesEditDialog(QDialog):
for key, sp in spins.items():
d[key] = sp.value()
return self._rates
class PurchaseDialog(QDialog):
"""Confirm a shopping list before adding it to shop inventory; optionally
save the entered prices back to the price book (opt-in)."""
def __init__(self, bought, prices, parent=None):
super().__init__(parent)
self.setWindowTitle("Mark purchased")
self.resize(360, 320)
v = QVBoxLayout(self)
v.addWidget(QLabel("Add these to your shop inventory?"))
rows = sorted(bought.items())
self._table = QTableWidget(len(rows), 3)
self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._spins = []
for r, (stock, qty) in enumerate(rows):
name = QTableWidgetItem(stock)
name.setFlags(name.flags() & ~Qt.ItemIsEditable)
self._table.setItem(r, 0, name)
q = QSpinBox(); q.setRange(0, 9999); q.setValue(int(qty))
p = QDoubleSpinBox(); p.setRange(0.0, 100000.0); p.setDecimals(2)
p.setValue(float(prices.get(stock, 0.0)))
self._table.setCellWidget(r, 1, q)
self._table.setCellWidget(r, 2, p)
self._spins.append((stock, q, p))
v.addWidget(self._table)
self._save = QCheckBox("Save these prices to my price book")
v.addWidget(self._save)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
v.addWidget(bb)
def result(self):
rows = [(stock, q.value(), round(p.value(), 2))
for stock, q, p in self._spins if q.value() > 0]
return rows, self._save.isChecked()
class RecordBuildDialog(QDialog):
"""Confirm what stock was consumed and decide each offcut's fate before the
build is committed to inventory (the moment to correct reality)."""
_FATES = ["keep", "burned", "trashed", "ignore"]
def __init__(self, consumed, offcuts, quantity, parent=None):
super().__init__(parent)
self.setWindowTitle("Record build")
self.resize(420, 420)
v = QVBoxLayout(self)
units = f" ×{quantity}" if quantity > 1 else ""
v.addWidget(QLabel(f"Recording a build{units}.\n\nConsumed from stock:"))
cons = ", ".join(f"{q} × {s}" for s, q in sorted(consumed.items())) or "(none)"
lbl = QLabel(" " + cons); lbl.setWordWrap(True)
v.addWidget(lbl)
v.addWidget(QLabel("Offcuts produced — keep, burn, trash, or ignore each:"))
self._table = QTableWidget(len(offcuts), 2)
self._table.setHorizontalHeaderLabels(["Offcut", "Fate"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._combos = []
for r, oc in enumerate(offcuts):
unit = "sheet" if oc["is_sheet"] else "stick"
desc = (f"{oc['stock']} {oc['length_in']:g}\" × {oc['width_in']:g}\""
if oc["is_sheet"] else f"{oc['stock']} {oc['length_in']:g}\" {unit}")
name = QTableWidgetItem(desc)
name.setFlags(name.flags() & ~Qt.ItemIsEditable)
self._table.setItem(r, 0, name)
combo = QComboBox(); combo.addItems(self._FATES)
self._table.setCellWidget(r, 1, combo)
self._combos.append(combo)
v.addWidget(self._table)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
v.addWidget(bb)
def dispositions(self):
return [c.currentText() for c in self._combos]

View File

@ -25,7 +25,7 @@ from collections import Counter
from dataclasses import asdict, dataclass, field
from pathlib import Path
from .lumber import is_plywood, normalize_stock
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, actual_section, is_plywood, normalize_stock
@dataclass
@ -51,7 +51,7 @@ def _data_path() -> Path:
def plan_consumption(plan) -> tuple[dict, list[dict]]:
"""From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts).
Offcuts are the reusable waste regions; ids are assigned later by the ledger."""
consumed = Counter(sp.stock for sp in plan.stock_pieces)
consumed = Counter(sp.stock for sp in plan.stock_pieces if not getattr(sp, "owned", False))
offcuts = []
for sp in plan.stock_pieces:
for w in sp.waste:
@ -114,26 +114,27 @@ class Ledger:
self._emit("consume", offcut_id=offcut_id)
def record_build(self, project: str, units: int, consumed: dict,
offcuts: list[dict], keep_ids=None, fates: dict | None = None,
offcuts: list[dict], dispositions: list | None = None,
cost: float | None = None, date: str = "") -> str:
"""Record a build: deduct consumed full stock, then for each produced
offcut either keep it (create_offcut) or discard it (burned/trashed).
`keep_ids`/`fates` are keyed by the offcut's assigned id (oc index order).
Returns the build id."""
offcut apply its disposition. `dispositions[i]` is one of "keep" ( offcut
bin), "burned"/"trashed" ( discard), or "ignore" (don't track). Defaults
to keeping every offcut. Returns the build id."""
build_id = self._next_build_id()
for stock, qty in consumed.items():
if qty:
self._emit("consume", stock=normalize_stock(stock), qty=int(qty),
build_id=build_id)
keep = set(keep_ids) if keep_ids is not None else set()
fates = fates or {}
disp = dispositions if dispositions is not None else ["keep"] * len(offcuts)
for i, oc in enumerate(offcuts):
fate = disp[i] if i < len(disp) else "keep"
oid = f"{build_id}-o{i + 1}"
if oid in keep or (keep_ids is None): # default: keep all offcuts
if fate == "keep":
self._emit("create_offcut", offcut={**oc, "id": oid,
"source_project": project, "source_build": build_id})
else:
self._emit("discard", offcut_id=oid, fate=fates.get(oid, "trashed"))
elif fate in ("burned", "trashed"):
self._emit("discard", offcut_id=oid, fate=fate)
# "ignore" -> not tracked
self._emit("build_recorded", build_id=build_id, project=project,
units=int(units), cost=cost, date=date)
return build_id
@ -165,14 +166,19 @@ class Ledger:
source_project=oc.get("source_project", ""), bin=oc.get("bin", ""))
for oc in live.values()]
def available_stock(self) -> list[Piece]:
"""Everything the planner could consume: full on-hand units + offcuts."""
def available_stock(self, stick_len_in: float = 96.0) -> list[Piece]:
"""Everything the planner could consume: full on-hand units (given real
dimensions so they pack) + offcuts."""
pieces = list(self.offcut_bin())
for stock, qty in self.on_hand().items():
sheet = is_plywood(stock)
if sheet:
L, W = SHEET_LENGTH_IN, SHEET_WIDTH_IN
else:
L, W = stick_len_in, actual_section(stock)[1]
for i in range(qty):
pieces.append(Piece(id=f"{stock}#{i + 1}", stock=stock,
length_in=0.0, width_in=0.0,
is_sheet=is_plywood(stock)))
length_in=L, width_in=W, is_sheet=sheet))
return pieces
def builds(self) -> list[dict]:

View File

@ -118,7 +118,8 @@ def estimate(plan, prices: dict[str, float] | None = None, hst: float = NB_HST)
prices = prices if prices is not None else load_prices()
stick_in = getattr(plan.settings, "stick_len_in", STD_STICK_IN) or STD_STICK_IN
counts = Counter(normalize_stock(sp.stock) for sp in plan.stock_pieces)
counts = Counter(normalize_stock(sp.stock) for sp in plan.stock_pieces
if not getattr(sp, "owned", False)) # offcuts are free, not bought
lines = []
for stock, qty in sorted(counts.items()):

View File

@ -125,3 +125,34 @@ def test_valid_move_commits(tmp_path):
second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear
w._drop_piece(second, home)
assert "placed" in w._status.text().lower()
def test_offcut_toggle_uses_inventory(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
from woodshop import inventory as I
led = I.Ledger()
led.record_build("seed", 1, consumed={}, offcuts=[
{"stock": "2x4", "length_in": 96, "width_in": 3.5, "is_sheet": False}])
led.save()
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 30)
w = BomWindow(c)
assert w._plan.score["stock_count"] == 1 # buys 1 stick by default
w._offcut_chk.setChecked(True) # use the 96" offcut instead
assert w._plan.score["stock_count"] == 0
assert w._plan.score["owned_count"] == 1
def test_record_build_writes_ledger(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
from woodshop import inventory as I
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 60)
w = BomWindow(c)
consumed, offcuts = I.plan_consumption(w._plan)
w._ledger.record_build("t", 1, consumed, offcuts, dispositions=["keep"] * len(offcuts))
w._ledger.save()
again = I.Ledger.load()
assert again.builds() and again.stats()["units_built"] == 1

View File

@ -25,7 +25,7 @@ def test_record_build_keeps_and_discards_offcuts():
offcuts = [{"stock": "2x4", "length_in": 20, "width_in": 3.5, "is_sheet": False},
{"stock": "2x4", "length_in": 8, "width_in": 3.5, "is_sheet": False}]
bid = led.record_build("shelf", 1, consumed={"2x4": 1}, offcuts=offcuts,
keep_ids={f"{ 'b1'}-o1"}, fates={"b1-o2": "burned"})
dispositions=["keep", "burned"])
bin_ = led.offcut_bin()
assert len(bin_) == 1 and bin_[0].id == "b1-o1" # kept the 20"
stats = led.stats()
@ -77,3 +77,17 @@ def test_stats_aggregate_across_projects():
st = led.stats()
assert st["units_built"] == 3 and st["builds"] == 2
assert st["by_project"] == {"table": 2, "shelf": 1}
def test_planner_consumes_offcuts_before_buying():
from woodshop.inventory import Piece
s = Scene()
s.place("2x4", 30)
# one 96" offcut on hand → the 30" piece should use it, buying 0 new sticks
offcut = Piece(id="oc1", stock="2x4", length_in=96, width_in=3.5,
is_sheet=False, is_offcut=True)
plan = build_cut_plan(s, available=[offcut])
assert plan.score["stock_count"] == 0 # bought nothing
assert plan.score["owned_count"] == 1
owned = [sp for sp in plan.stock_pieces if sp.owned]
assert owned and owned[0].placements # the 30" sits in the offcut