diff --git a/MATERIALS_INVENTORY_PLAN.md b/MATERIALS_INVENTORY_PLAN.md index e60c6ce..473cbce 100644 --- a/MATERIALS_INVENTORY_PLAN.md +++ b/MATERIALS_INVENTORY_PLAN.md @@ -191,8 +191,10 @@ Each phase committed separately; phases 5–7 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 diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index 2b8517e..b4821dd 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -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: diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 2082132..4984282 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -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 diff --git a/src/woodshop/inventory.py b/src/woodshop/inventory.py index 7414769..b8afad1 100644 --- a/src/woodshop/inventory.py +++ b/src/woodshop/inventory.py @@ -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()] diff --git a/src/woodshop/prices.py b/src/woodshop/prices.py index 196d4f0..70727f9 100644 --- a/src/woodshop/prices.py +++ b/src/woodshop/prices.py @@ -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}", diff --git a/tests/test_prices.py b/tests/test_prices.py index 6ba8ffc..c137d0f 100644 --- a/tests/test_prices.py +++ b/tests/test_prices.py @@ -75,3 +75,48 @@ def test_parse_price_reads_json_ld(): def test_parse_price_none_when_absent(): assert P._parse_price("no price here") 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"]