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 dataclasses import asdict, dataclass, field, fields
from .cutlist import cut_length 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 _EPS = 1e-6
@ -103,6 +103,8 @@ class StockPiece:
width_in: float width_in: float
placements: list = field(default_factory=list) # Placement placements: list = field(default_factory=list) # Placement
waste: list = field(default_factory=list) # WasteRegion 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 @dataclass
@ -137,7 +139,8 @@ class CutPlan:
id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"], id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"],
length_in=s["length_in"], width_in=s["width_in"], length_in=s["length_in"], width_in=s["width_in"],
placements=[Placement(**p) for p in s.get("placements", [])], 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( return cls(
settings=ShopSettings.from_dict(d.get("settings")), settings=ShopSettings.from_dict(d.get("settings")),
items=[CutItem(**i) for i in d.get("items", [])], 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) 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, 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 from dataclasses import replace
s = settings or ShopSettings() s = settings or ShopSettings()
@ -455,9 +471,15 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
stock_pieces, unplaced, warnings = [], [], [] stock_pieces, unplaced, warnings = [], [], []
for stock, its in by_stock.items(): for stock, its in by_stock.items():
seeds = _offcut_seeds(available, stock, ids)
if its[0].is_sheet: if its[0].is_sheet:
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" sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine"
else _pack_plywood(its, stock, s, ids)) else _pack_plywood(its, stock, s, ids))
elif seeds:
sps, un = _pack_lumber_seeded(its, stock, s, ids, seeds)
elif strategy == "exact": elif strategy == "exact":
sps, un = _pack_lumber_exact(its, stock, s, ids) sps, un = _pack_lumber_exact(its, stock, s, ids)
else: else:
@ -478,13 +500,16 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
def _score(stock_pieces, s, strategy, warnings) -> dict: def _score(stock_pieces, s, strategy, warnings) -> dict:
waste_area = used_area = bought_area = 0.0 waste_area = used_area = bought_area = 0.0
reusable = 0 reusable = 0
bought = [sp for sp in stock_pieces if not sp.owned]
for sp in stock_pieces: for sp in stock_pieces:
used = sum(p.len_in * p.wid_in for p in sp.placements) used = sum(p.len_in * p.wid_in for p in sp.placements)
used_area += used used_area += used
if sp.is_sheet: if sp.is_sheet:
if not sp.owned:
bought_area += sp.length_in * sp.width_in bought_area += sp.length_in * sp.width_in
waste_area += sp.length_in * sp.width_in - used waste_area += sp.length_in * sp.width_in - used
else: else:
if not sp.owned:
bought_area += sp.length_in * sp.width_in bought_area += sp.length_in * sp.width_in
for w in sp.waste: for w in sp.waste:
waste_area += w.length_in * (w.width_in or sp.width_in) waste_area += w.length_in * (w.width_in or sp.width_in)
@ -494,7 +519,8 @@ def _score(stock_pieces, s, strategy, warnings) -> dict:
for w in sp.waste if w.reusable) for w in sp.waste if w.reusable)
return { return {
"strategy_name": strategy, "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), "waste_area": round(waste_area, 1),
"reusable_offcuts": reusable, "reusable_offcuts": reusable,
"reusable_in": round(reusable_in, 1), "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, 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 """Find a better layout by trying several strategies + shuffle restarts and
keeping the best-scoring one. (Good and explainable, not provably optimal.)""" keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"] strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))] strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
best = None best = None
for st in strategies: 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): if best is None or _plan_key(plan) < _plan_key(best):
best = plan best = plan
if best is not None: if best is not None:

View File

@ -8,12 +8,12 @@ import subprocess
from PySide6.QtCore import Qt, QThreadPool from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QBrush, QColor, QFont, QPen from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QGraphicsItem, QGraphicsRectItem, QGraphicsScene, QDoubleSpinBox, QFormLayout, QGraphicsItem, QGraphicsRectItem,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea, QHBoxLayout, QHeaderView, QLabel, QMenu, QMessageBox,
QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget, QPushButton, QScrollArea, QSpinBox, QTableWidget,
QTextEdit, QVBoxLayout, QWidget) QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from collections import Counter 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 ..jigs import explain_prompt, format_jigs, suggest_jigs
from .. import prices as prices_mod from .. import prices as prices_mod
from .. import estimate as estimate_mod from .. import estimate as estimate_mod
from .. import inventory as inventory_mod
from ..inventory import plan_consumption
from .workers import run_async from .workers import run_async
_PX = 7.0 # pixels per inch in the layout view _PX = 7.0 # pixels per inch in the layout view
@ -82,6 +84,8 @@ class BomWindow(QDialog):
self._prices = prices_mod.load_prices() self._prices = prices_mod.load_prices()
self._rates = estimate_mod.load_rates() self._rates = estimate_mod.load_rates()
self._ledger = inventory_mod.Ledger.load()
self._use_offcuts = False
self._cut_te = self._mono_te() self._cut_te = self._mono_te()
self._shop_te = self._mono_te() self._shop_te = self._mono_te()
tabs = QTabWidget() tabs = QTabWidget()
@ -92,7 +96,8 @@ class BomWindow(QDialog):
tabs.addTab(self._instructions_tab(), "Instructions") tabs.addTab(self._instructions_tab(), "Instructions")
tabs.addTab(self._jigs_tab(), "Jigs") 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 = QHBoxLayout()
header.addWidget(QLabel("Build units:")) header.addWidget(QLabel("Build units:"))
self._qty_spin = QSpinBox() self._qty_spin = QSpinBox()
@ -102,16 +107,83 @@ class BomWindow(QDialog):
"carry across units") "carry across units")
self._qty_spin.valueChanged.connect(self._on_quantity_changed) self._qty_spin.valueChanged.connect(self._on_quantity_changed)
header.addWidget(self._qty_spin) 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() 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 = QVBoxLayout(self)
root.addLayout(header) root.addLayout(header)
root.addWidget(tabs) root.addWidget(tabs)
self._refresh_all() 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: def _on_quantity_changed(self, value: int) -> None:
self._quantity = max(1, value) 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 --------------------- # ----- one active plan; all tabs render from it ---------------------
def _set_plan(self, plan) -> None: def _set_plan(self, plan) -> None:
@ -171,12 +243,16 @@ class BomWindow(QDialog):
def _shop_text(self) -> str: def _shop_text(self) -> str:
plan = self._plan plan = self._plan
lines = ["SHOPPING LIST", "", "Buy:"] 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 "" s = "s" if qty != 1 else ""
unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')" unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')"
lines.append(f" {qty} × {stock} {unit}") lines.append(f" {qty} × {stock} {unit}")
if not plan.stock_pieces: if not bought:
lines.append(" (nothing yet)") 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: if plan.unplaced:
lines += ["", "⚠ Won't fit standard stock — source / cut specially:"] lines += ["", "⚠ Won't fit standard stock — source / cut specially:"]
for iid in plan.unplaced: for iid in plan.unplaced:
@ -701,3 +777,81 @@ class RatesEditDialog(QDialog):
for key, sp in spins.items(): for key, sp in spins.items():
d[key] = sp.value() d[key] = sp.value()
return self._rates 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 dataclasses import asdict, dataclass, field
from pathlib import Path 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 @dataclass
@ -51,7 +51,7 @@ def _data_path() -> Path:
def plan_consumption(plan) -> tuple[dict, list[dict]]: def plan_consumption(plan) -> tuple[dict, list[dict]]:
"""From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts). """From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts).
Offcuts are the reusable waste regions; ids are assigned later by the ledger.""" 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 = [] offcuts = []
for sp in plan.stock_pieces: for sp in plan.stock_pieces:
for w in sp.waste: for w in sp.waste:
@ -114,26 +114,27 @@ class Ledger:
self._emit("consume", offcut_id=offcut_id) self._emit("consume", offcut_id=offcut_id)
def record_build(self, project: str, units: int, consumed: dict, 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: cost: float | None = None, date: str = "") -> str:
"""Record a build: deduct consumed full stock, then for each produced """Record a build: deduct consumed full stock, then for each produced
offcut either keep it (create_offcut) or discard it (burned/trashed). offcut apply its disposition. `dispositions[i]` is one of "keep" ( offcut
`keep_ids`/`fates` are keyed by the offcut's assigned id (oc index order). bin), "burned"/"trashed" ( discard), or "ignore" (don't track). Defaults
Returns the build id.""" to keeping every offcut. Returns the build id."""
build_id = self._next_build_id() build_id = self._next_build_id()
for stock, qty in consumed.items(): for stock, qty in consumed.items():
if qty: if qty:
self._emit("consume", stock=normalize_stock(stock), qty=int(qty), self._emit("consume", stock=normalize_stock(stock), qty=int(qty),
build_id=build_id) build_id=build_id)
keep = set(keep_ids) if keep_ids is not None else set() disp = dispositions if dispositions is not None else ["keep"] * len(offcuts)
fates = fates or {}
for i, oc in enumerate(offcuts): for i, oc in enumerate(offcuts):
fate = disp[i] if i < len(disp) else "keep"
oid = f"{build_id}-o{i + 1}" 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, self._emit("create_offcut", offcut={**oc, "id": oid,
"source_project": project, "source_build": build_id}) "source_project": project, "source_build": build_id})
else: elif fate in ("burned", "trashed"):
self._emit("discard", offcut_id=oid, fate=fates.get(oid, "trashed")) self._emit("discard", offcut_id=oid, fate=fate)
# "ignore" -> not tracked
self._emit("build_recorded", build_id=build_id, project=project, self._emit("build_recorded", build_id=build_id, project=project,
units=int(units), cost=cost, date=date) units=int(units), cost=cost, date=date)
return build_id return build_id
@ -165,14 +166,19 @@ class Ledger:
source_project=oc.get("source_project", ""), bin=oc.get("bin", "")) source_project=oc.get("source_project", ""), bin=oc.get("bin", ""))
for oc in live.values()] for oc in live.values()]
def available_stock(self) -> list[Piece]: def available_stock(self, stick_len_in: float = 96.0) -> list[Piece]:
"""Everything the planner could consume: full on-hand units + offcuts.""" """Everything the planner could consume: full on-hand units (given real
dimensions so they pack) + offcuts."""
pieces = list(self.offcut_bin()) pieces = list(self.offcut_bin())
for stock, qty in self.on_hand().items(): 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): for i in range(qty):
pieces.append(Piece(id=f"{stock}#{i + 1}", stock=stock, pieces.append(Piece(id=f"{stock}#{i + 1}", stock=stock,
length_in=0.0, width_in=0.0, length_in=L, width_in=W, is_sheet=sheet))
is_sheet=is_plywood(stock)))
return pieces return pieces
def builds(self) -> list[dict]: 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() 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 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 = [] lines = []
for stock, qty in sorted(counts.items()): 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 second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear
w._drop_piece(second, home) w._drop_piece(second, home)
assert "placed" in w._status.text().lower() 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}, 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}] {"stock": "2x4", "length_in": 8, "width_in": 3.5, "is_sheet": False}]
bid = led.record_build("shelf", 1, consumed={"2x4": 1}, offcuts=offcuts, 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() bin_ = led.offcut_bin()
assert len(bin_) == 1 and bin_[0].id == "b1-o1" # kept the 20" assert len(bin_) == 1 and bin_[0].id == "b1-o1" # kept the 20"
stats = led.stats() stats = led.stats()
@ -77,3 +77,17 @@ def test_stats_aggregate_across_projects():
st = led.stats() st = led.stats()
assert st["units_built"] == 3 and st["builds"] == 2 assert st["units_built"] == 3 and st["builds"] == 2
assert st["by_project"] == {"table": 2, "shelf": 1} 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