Material-aware pricing: oak ≠ pine

Resolves the v1 simplification where every species cost the same.

- CutItem/StockPiece gain `material`; build_cut_plan and reoptimize group by
  (stock, material) so each bought stick/sheet is a single species; pieces are
  stamped with their material (offcut seeds keep theirs).
- prices.estimate groups by (stock, species) and applies a per-species
  multiplier (DEFAULT_MATERIAL_MULTIPLIERS: SPF 1.0, oak 3.0, walnut 5.5, …),
  persisted to material_multipliers.json. CostLine shows "oak 1x4".
- PriceEditDialog gains a species-multiplier table; BOM Buy list shows species.
- Offcuts carry material so offcut reuse matches species.
- tests: multiplier scales price, default species at base, mixed species on the
  same stock priced separately, multiplier save/load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 19:48:26 -03:00
parent 2ee4c56b3a
commit 36d02fcb73
6 changed files with 183 additions and 53 deletions

View File

@ -191,8 +191,10 @@ Each phase committed separately; phases 57 are the big block, built model-fir
- **Sanding:** rough-vs-final allowance on cut dims only; design dims stay final;
lumber section not padded. (Rob + Codex.)
- **Finish:** single `finish` enum + color now; sheen later.
- **Pricing:** stock-based in v1; price key designed to extend to (stock,
material, grade).
- **Pricing:** ~~stock-based in v1~~ **now material-aware** — the planner groups
stock by (stock, species) so each bought piece is one material, and the price
book applies a per-species multiplier (SPF 1.0, oak 3.0, walnut 5.5, …),
editable in the price dialog. (B1 resolved.)
- **Batch labor:** setup once per batch, per-op ×N.
- **Inventory:** event-sourced source of truth; workflow-first UX; window second.
- **Still genuinely open:** auto-update price book from recorded purchase prices

View File

@ -14,7 +14,8 @@ 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, normalize_stock
from .lumber import (SHEET_LENGTH_IN, SHEET_WIDTH_IN, default_material, is_plywood,
normalize_stock)
_EPS = 1e-6
@ -60,6 +61,7 @@ class CutItem:
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
final_width_in: float = 0.0
unit: int = 1 # which build unit (batch quantity > 1)
material: str = "" # species/sheet (drives price multiplier)
@property
def final_len(self) -> float:
@ -105,6 +107,7 @@ class StockPiece:
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
material: str = "" # species/sheet of this bought piece (drives price)
@dataclass
@ -140,7 +143,8 @@ class CutPlan:
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", [])],
owned=s.get("owned", False), source=s.get("source", ""))
owned=s.get("owned", False), source=s.get("source", ""),
material=s.get("material", ""))
return cls(
settings=ShopSettings.from_dict(d.get("settings")),
items=[CutItem(**i) for i in d.get("items", [])],
@ -174,7 +178,8 @@ def _cut_items(scene, settings: "ShopSettings | None" = None) -> list:
id=f"ci{n}", part_id=p.id, stock=p.stock,
length_in=rough_len, width_in=rough_wid,
final_length_in=final_len, final_width_in=final_wid,
is_sheet=sheet, note=note))
is_sheet=sheet, note=note,
material=getattr(p, "material", "") or default_material(p.stock)))
return items
@ -434,15 +439,20 @@ 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."""
def _offcut_seeds(available, stock, material, ids) -> list:
"""StockPieces representing owned offcuts of this (stock, material), to fill
before buying. An offcut with no recorded material matches any species."""
seeds = []
for oc in available or []:
if normalize_stock(getattr(oc, "stock", "")) != stock:
continue
oc_mat = getattr(oc, "material", "")
if oc_mat and material and oc_mat != material:
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", "")))
owned=True, source=getattr(oc, "id", ""),
material=oc_mat or material))
return seeds
@ -465,13 +475,13 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
return f"{prefix}{counter['n']}"
fit = "best" if strategy == "bestfit" else "first"
by_stock: dict[str, list] = {}
for it in _ordered(items, strategy):
by_stock.setdefault(it.stock, []).append(it)
by_group: dict[tuple, list] = {} # group by (stock, material) so each
for it in _ordered(items, strategy): # bought piece is a single species
by_group.setdefault((it.stock, it.material), []).append(it)
stock_pieces, unplaced, warnings = [], [], []
for stock, its in by_stock.items():
seeds = _offcut_seeds(available, stock, ids)
for (stock, material), its in by_group.items():
seeds = _offcut_seeds(available, stock, material, ids)
if its[0].is_sheet:
if seeds:
sps, un = _pack_plywood_seeded(its, stock, s, ids, seeds)
@ -484,6 +494,9 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
sps, un = _pack_lumber_exact(its, stock, s, ids)
else:
sps, un = _pack_lumber(its, stock, s, ids, fit=fit)
for sp in sps:
if not sp.material:
sp.material = material # stamp species (offcut seeds keep theirs)
stock_pieces += sps
unplaced += un
for item_id in unplaced:
@ -564,33 +577,38 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
counter["n"] += 1
return f"{prefix}{counter['n']}"
# Seed stock pieces from those holding locked placements (keep only locked).
seeds: dict[str, dict] = {}
# Seed stock pieces from those holding locked placements (keep only locked),
# keyed by (stock, material) so species don't get mixed onto one piece.
seeds: dict[tuple, dict] = {}
for sp in base_plan.stock_pieces:
kept = [p for p in sp.placements if p.locked]
if not kept:
continue
seeds.setdefault(sp.stock, {})[sp.id] = StockPiece(
seeds.setdefault((sp.stock, sp.material), {})[sp.id] = StockPiece(
id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet,
length_in=sp.length_in, width_in=sp.width_in,
length_in=sp.length_in, width_in=sp.width_in, material=sp.material,
placements=[Placement(id=p.id, item_id=p.item_id, x_in=p.x_in, y_in=p.y_in,
len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True)
for p in kept])
unlocked = [it for it in _ordered(items, strategy) if it.id not in locked_ids]
by_stock: dict[str, list] = {}
by_group: dict[tuple, list] = {}
for it in unlocked:
by_stock.setdefault(it.stock, []).append(it)
by_group.setdefault((it.stock, it.material), []).append(it)
stock_pieces, unplaced, warnings = [], [], []
for stock in set(by_stock) | set(seeds):
its = by_stock.get(stock, [])
seed_pieces = list(seeds.get(stock, {}).values())
for key in set(by_group) | set(seeds):
stock, material = key
its = by_group.get(key, [])
seed_pieces = list(seeds.get(key, {}).values())
is_sheet = (its and its[0].is_sheet) or (seed_pieces and seed_pieces[0].is_sheet)
if is_sheet:
sps, un = _pack_plywood_seeded(its, stock, s, ids, seed_pieces)
else:
sps, un = _pack_lumber_seeded(its, stock, s, ids, seed_pieces)
for sp in sps:
if not sp.material:
sp.material = material
stock_pieces += sps
unplaced += un
for iid in unplaced:

View File

@ -242,12 +242,14 @@ class BomWindow(QDialog):
def _shop_text(self) -> str:
plan = self._plan
from ..lumber import default_material
lines = ["SHOPPING LIST", "", "Buy:"]
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()):
for (stock, mat), qty in sorted(Counter((sp.stock, sp.material) 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}")
name = f"{mat} {stock}" if mat and mat != default_material(stock) else stock
lines.append(f" {qty} × {name} {unit}")
if not bought:
lines.append(" (nothing to buy)")
owned = [sp for sp in plan.stock_pieces if getattr(sp, "owned", False)]
@ -349,10 +351,12 @@ class BomWindow(QDialog):
self._cost_te.setPlainText(self._cost_text())
def _edit_prices(self) -> None:
dlg = PriceEditDialog(self._prices, self)
mults = prices_mod.load_material_multipliers()
dlg = PriceEditDialog(self._prices, mults, self)
if dlg.exec():
self._prices = dlg.prices()
prices_mod.save_prices(self._prices)
prices_mod.save_material_multipliers(dlg.multipliers())
self._cost_te.setPlainText(self._cost_text())
def _refresh_prices(self) -> None:
@ -674,40 +678,54 @@ class BomWindow(QDialog):
class PriceEditDialog(QDialog):
"""Edit the per-unit price book (lumber = 8' stick, plywood = 4×8 sheet)."""
"""Edit the per-unit price book (lumber = 8' stick, plywood = 4×8 sheet) and
the per-species price multipliers (oak vs SPF, etc.)."""
def __init__(self, prices: dict, parent=None):
def __init__(self, prices: dict, multipliers: dict, parent=None):
super().__init__(parent)
self.setWindowTitle("Edit prices")
self.resize(360, 460)
self.resize(380, 560)
v = QVBoxLayout(self)
v.addWidget(QLabel("Price per stick (8') / sheet (4×8), in CAD.\n"
v.addWidget(QLabel("Price per stick (8') / sheet (4×8), in CAD (SPF base).\n"
"Seeded from Kent NB — edit as needed."))
rows = sorted(prices.items())
self._table = QTableWidget(len(rows), 2)
self._table.setHorizontalHeaderLabels(["Stock", "Price $"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
for r, (stock, price) in enumerate(rows):
name = QTableWidgetItem(stock)
name.setFlags(name.flags() & ~Qt.ItemIsEditable) # stock names are fixed
self._table.setItem(r, 0, name)
self._table.setItem(r, 1, QTableWidgetItem(f"{float(price):.2f}"))
self._table = self._make_table(prices, "Stock", "Price $", "{:.2f}")
v.addWidget(self._table)
v.addWidget(QLabel("Species multiplier (× the base price; SPF = 1.0):"))
self._mtable = self._make_table(multipliers, "Species", "× factor", "{:g}")
v.addWidget(self._mtable)
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
v.addWidget(bb)
def prices(self) -> dict:
def _make_table(self, data, col0, col1, fmt):
rows = sorted(data.items())
t = QTableWidget(len(rows), 2)
t.setHorizontalHeaderLabels([col0, col1])
t.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
for r, (k, val) in enumerate(rows):
name = QTableWidgetItem(k)
name.setFlags(name.flags() & ~Qt.ItemIsEditable)
t.setItem(r, 0, name)
t.setItem(r, 1, QTableWidgetItem(fmt.format(float(val))))
return t
@staticmethod
def _read(table) -> dict:
out = {}
for r in range(self._table.rowCount()):
stock = self._table.item(r, 0).text()
for r in range(table.rowCount()):
try:
out[stock] = round(float(self._table.item(r, 1).text()), 2)
out[table.item(r, 0).text()] = round(float(table.item(r, 1).text()), 3)
except (ValueError, AttributeError):
continue # skip blanked / bad cells
continue
return out
def prices(self) -> dict:
return self._read(self._table)
def multipliers(self) -> dict:
return self._read(self._mtable)
class RatesEditDialog(QDialog):
"""Edit labor rate, per-operation minutes, and consumable costs. Renders

View File

@ -59,6 +59,7 @@ def plan_consumption(plan) -> tuple[dict, list[dict]]:
continue
offcuts.append({
"stock": sp.stock,
"material": getattr(sp, "material", ""),
"length_in": round(w.length_in, 2),
"width_in": round(w.width_in or sp.width_in, 2),
"is_sheet": sp.is_sheet,
@ -163,6 +164,7 @@ class Ledger:
del live[e["offcut_id"]]
return [Piece(id=oc["id"], stock=oc["stock"], length_in=oc["length_in"],
width_in=oc["width_in"], is_sheet=oc["is_sheet"], is_offcut=True,
material=oc.get("material", ""),
source_project=oc.get("source_project", ""), bin=oc.get("bin", ""))
for oc in live.values()]

View File

@ -39,6 +39,39 @@ DEFAULT_PRICES: dict[str, float] = {
"ply-3/4": 63.98, # ✓ 3/4 4x8 spruce $63.98
}
# Per-species price multiplier, relative to the stock-based (SPF) price. Lets a
# 2x4 oak diverge from a 2x4 SPF without exploding the price book into every
# stock×species pair. Editable; persisted to material_multipliers.json.
DEFAULT_MATERIAL_MULTIPLIERS: dict[str, float] = {
"spruce": 1.0, "pine": 1.3, "fir": 1.1, "cedar": 2.0, "mdf": 0.8,
"oak": 3.0, "maple": 3.2, "birch": 2.8, "walnut": 5.5, "cherry": 4.5,
"spruce-ply": 1.0,
}
def _mult_path() -> Path:
base = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "woodshop"
return base / "material_multipliers.json"
def load_material_multipliers() -> dict[str, float]:
mults = dict(DEFAULT_MATERIAL_MULTIPLIERS)
path = _mult_path()
if path.exists():
try:
mults.update({k: float(v) for k, v in json.loads(path.read_text()).items()})
except (ValueError, OSError):
pass
return mults
def save_material_multipliers(mults: dict[str, float]) -> None:
path = _mult_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({k: round(float(v), 3) for k, v in sorted(mults.items())},
indent=2))
# Kent product pages for the refresh script (stock -> URL). Confirmed-real URLs.
KENT_URLS: dict[str, str] = {
"2x4": "https://kent.ca/en/2-x-4-x-8-spf-stud-kiln-dried-1016318",
@ -83,11 +116,19 @@ class CostLine:
qty: int
unit_price: float | None # None = no price on file
unit_label: str
material: str = "" # species this line was priced for
@property
def total(self) -> float:
return round((self.unit_price or 0.0) * self.qty, 2)
@property
def label(self) -> str:
from .lumber import default_material
if self.material and self.material != default_material(self.stock):
return f"{self.material} {self.stock}" # e.g. "oak 1x4"
return self.stock
@dataclass
class CostEstimate:
@ -111,27 +152,31 @@ class CostEstimate:
return [ln.stock for ln in self.lines if ln.unit_price is None]
def estimate(plan, prices: dict[str, float] | None = None, hst: float = NB_HST) -> CostEstimate:
"""Cost of buying the stock a CutPlan calls for. Lumber prices scale with the
plan's stick length (priced per foot off an 8' stick); plywood is per sheet."""
def estimate(plan, prices: dict[str, float] | None = None, hst: float = NB_HST,
multipliers: dict[str, float] | None = None) -> CostEstimate:
"""Cost of buying the stock a CutPlan calls for, grouped by (stock, species).
Lumber prices scale with the plan's stick length (priced per foot off an 8'
stick); plywood is per sheet; the species multiplier scales both."""
from collections import Counter
prices = prices if prices is not None else load_prices()
mults = multipliers if multipliers is not None else load_material_multipliers()
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), getattr(sp, "material", ""))
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()):
for (stock, material), qty in sorted(counts.items()):
base = prices.get(stock)
if base is None:
unit_price = None
elif stock.startswith("ply-"):
unit_price = round(base, 2) # per sheet
else:
unit_price = round(base * (stick_in / STD_STICK_IN), 2) # per actual stick
mult = mults.get(material, 1.0) if material else 1.0
per = base if stock.startswith("ply-") else base * (stick_in / STD_STICK_IN)
unit_price = round(per * mult, 2)
lines.append(CostLine(stock=stock, qty=qty, unit_price=unit_price,
unit_label=unit_label(stock)))
unit_label=unit_label(stock), material=material))
return CostEstimate(lines=lines, hst=hst)
@ -231,7 +276,7 @@ def format_estimate(est: CostEstimate, region: str = "Kent NB") -> str:
for ln in est.lines:
price = _money(ln.unit_price) if ln.unit_price is not None else ""
total = _money(ln.total) if ln.unit_price is not None else ""
lines.append(f" {ln.stock:<9} {ln.qty:>2} × {ln.unit_label:<12} "
lines.append(f" {ln.label:<14} {ln.qty:>2} × {ln.unit_label:<12} "
f"{price:>9} {total:>10}")
lines += [" " + "-" * 46,
f" {'Subtotal':<38}{_money(est.subtotal):>10}",

View File

@ -75,3 +75,48 @@ def test_parse_price_reads_json_ld():
def test_parse_price_none_when_absent():
assert P._parse_price("<html><body>no price here</body></html>") is None
def test_material_multiplier_scales_price():
from woodshop.scene import Scene
s = Scene()
s.place("1x4", 24)
s.set_material("p1", "oak")
plan = build_cut_plan(s)
est = P.estimate(plan, prices={"1x4": 5.0}, hst=0.0,
multipliers={"oak": 3.0, "spruce": 1.0})
line = next(ln for ln in est.lines if ln.stock == "1x4")
assert line.material == "oak"
assert line.unit_price == 15.0 # 5 × 3.0
assert "oak" in line.label
def test_default_species_priced_at_base():
from woodshop.scene import Scene
s = Scene()
s.place("2x4", 24) # default spruce, mult 1.0
plan = build_cut_plan(s)
est = P.estimate(plan, prices={"2x4": 4.0}, hst=0.0)
line = next(ln for ln in est.lines if ln.stock == "2x4")
assert line.unit_price == 4.0 and line.label == "2x4"
def test_mixed_species_same_stock_priced_separately():
from woodshop.scene import Scene
s = Scene()
s.place("1x4", 24)
s.place("1x4", 24)
s.set_material("p2", "walnut")
plan = build_cut_plan(s) # groups (1x4,spruce) and (1x4,walnut)
est = P.estimate(plan, prices={"1x4": 5.0}, hst=0.0,
multipliers={"spruce": 1.0, "walnut": 5.0})
prices = {ln.material: ln.unit_price for ln in est.lines}
assert prices["spruce"] == 5.0 and prices["walnut"] == 25.0
def test_multipliers_save_load(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
P.save_material_multipliers({"oak": 3.5, "walnut": 6.0})
loaded = P.load_material_multipliers()
assert loaded["oak"] == 3.5 and loaded["walnut"] == 6.0
assert loaded["spruce"] == P.DEFAULT_MATERIAL_MULTIPLIERS["spruce"]