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:
parent
2ee4c56b3a
commit
36d02fcb73
|
|
@ -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;
|
- **Sanding:** rough-vs-final allowance on cut dims only; design dims stay final;
|
||||||
lumber section not padded. (Rob + Codex.)
|
lumber section not padded. (Rob + Codex.)
|
||||||
- **Finish:** single `finish` enum + color now; sheen later.
|
- **Finish:** single `finish` enum + color now; sheen later.
|
||||||
- **Pricing:** stock-based in v1; price key designed to extend to (stock,
|
- **Pricing:** ~~stock-based in v1~~ **now material-aware** — the planner groups
|
||||||
material, grade).
|
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.
|
- **Batch labor:** setup once per batch, per-op ×N.
|
||||||
- **Inventory:** event-sourced source of truth; workflow-first UX; window second.
|
- **Inventory:** event-sourced source of truth; workflow-first UX; window second.
|
||||||
- **Still genuinely open:** auto-update price book from recorded purchase prices
|
- **Still genuinely open:** auto-update price book from recorded purchase prices
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ 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, normalize_stock
|
from .lumber import (SHEET_LENGTH_IN, SHEET_WIDTH_IN, default_material, is_plywood,
|
||||||
|
normalize_stock)
|
||||||
|
|
||||||
_EPS = 1e-6
|
_EPS = 1e-6
|
||||||
|
|
||||||
|
|
@ -60,6 +61,7 @@ class CutItem:
|
||||||
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
|
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
|
||||||
final_width_in: float = 0.0
|
final_width_in: float = 0.0
|
||||||
unit: int = 1 # which build unit (batch quantity > 1)
|
unit: int = 1 # which build unit (batch quantity > 1)
|
||||||
|
material: str = "" # species/sheet (drives price multiplier)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def final_len(self) -> float:
|
def final_len(self) -> float:
|
||||||
|
|
@ -105,6 +107,7 @@ class StockPiece:
|
||||||
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)
|
owned: bool = False # True = an offcut you already have (not bought)
|
||||||
source: str = "" # offcut id / origin, when owned
|
source: str = "" # offcut id / origin, when owned
|
||||||
|
material: str = "" # species/sheet of this bought piece (drives price)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -140,7 +143,8 @@ class CutPlan:
|
||||||
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", ""))
|
owned=s.get("owned", False), source=s.get("source", ""),
|
||||||
|
material=s.get("material", ""))
|
||||||
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", [])],
|
||||||
|
|
@ -174,7 +178,8 @@ def _cut_items(scene, settings: "ShopSettings | None" = None) -> list:
|
||||||
id=f"ci{n}", part_id=p.id, stock=p.stock,
|
id=f"ci{n}", part_id=p.id, stock=p.stock,
|
||||||
length_in=rough_len, width_in=rough_wid,
|
length_in=rough_len, width_in=rough_wid,
|
||||||
final_length_in=final_len, final_width_in=final_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
|
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)
|
return _guillotine_pack(items, stock, s, ids, seed_sheets)
|
||||||
|
|
||||||
|
|
||||||
def _offcut_seeds(available, stock, ids) -> list:
|
def _offcut_seeds(available, stock, material, ids) -> list:
|
||||||
"""StockPieces representing owned offcuts of `stock`, to fill before buying."""
|
"""StockPieces representing owned offcuts of this (stock, material), to fill
|
||||||
|
before buying. An offcut with no recorded material matches any species."""
|
||||||
seeds = []
|
seeds = []
|
||||||
for oc in available or []:
|
for oc in available or []:
|
||||||
if normalize_stock(getattr(oc, "stock", "")) != stock:
|
if normalize_stock(getattr(oc, "stock", "")) != stock:
|
||||||
continue
|
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,
|
seeds.append(StockPiece(id=ids("oc"), stock=stock, is_sheet=oc.is_sheet,
|
||||||
length_in=oc.length_in, width_in=oc.width_in,
|
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
|
return seeds
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -465,13 +475,13 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||||
return f"{prefix}{counter['n']}"
|
return f"{prefix}{counter['n']}"
|
||||||
|
|
||||||
fit = "best" if strategy == "bestfit" else "first"
|
fit = "best" if strategy == "bestfit" else "first"
|
||||||
by_stock: dict[str, list] = {}
|
by_group: dict[tuple, list] = {} # group by (stock, material) so each
|
||||||
for it in _ordered(items, strategy):
|
for it in _ordered(items, strategy): # bought piece is a single species
|
||||||
by_stock.setdefault(it.stock, []).append(it)
|
by_group.setdefault((it.stock, it.material), []).append(it)
|
||||||
|
|
||||||
stock_pieces, unplaced, warnings = [], [], []
|
stock_pieces, unplaced, warnings = [], [], []
|
||||||
for stock, its in by_stock.items():
|
for (stock, material), its in by_group.items():
|
||||||
seeds = _offcut_seeds(available, stock, ids)
|
seeds = _offcut_seeds(available, stock, material, ids)
|
||||||
if its[0].is_sheet:
|
if its[0].is_sheet:
|
||||||
if seeds:
|
if seeds:
|
||||||
sps, un = _pack_plywood_seeded(its, stock, s, ids, 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)
|
sps, un = _pack_lumber_exact(its, stock, s, ids)
|
||||||
else:
|
else:
|
||||||
sps, un = _pack_lumber(its, stock, s, ids, fit=fit)
|
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
|
stock_pieces += sps
|
||||||
unplaced += un
|
unplaced += un
|
||||||
for item_id in unplaced:
|
for item_id in unplaced:
|
||||||
|
|
@ -564,33 +577,38 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
|
||||||
counter["n"] += 1
|
counter["n"] += 1
|
||||||
return f"{prefix}{counter['n']}"
|
return f"{prefix}{counter['n']}"
|
||||||
|
|
||||||
# Seed stock pieces from those holding locked placements (keep only locked).
|
# Seed stock pieces from those holding locked placements (keep only locked),
|
||||||
seeds: dict[str, dict] = {}
|
# keyed by (stock, material) so species don't get mixed onto one piece.
|
||||||
|
seeds: dict[tuple, dict] = {}
|
||||||
for sp in base_plan.stock_pieces:
|
for sp in base_plan.stock_pieces:
|
||||||
kept = [p for p in sp.placements if p.locked]
|
kept = [p for p in sp.placements if p.locked]
|
||||||
if not kept:
|
if not kept:
|
||||||
continue
|
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,
|
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,
|
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)
|
len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True)
|
||||||
for p in kept])
|
for p in kept])
|
||||||
|
|
||||||
unlocked = [it for it in _ordered(items, strategy) if it.id not in locked_ids]
|
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:
|
for it in unlocked:
|
||||||
by_stock.setdefault(it.stock, []).append(it)
|
by_group.setdefault((it.stock, it.material), []).append(it)
|
||||||
|
|
||||||
stock_pieces, unplaced, warnings = [], [], []
|
stock_pieces, unplaced, warnings = [], [], []
|
||||||
for stock in set(by_stock) | set(seeds):
|
for key in set(by_group) | set(seeds):
|
||||||
its = by_stock.get(stock, [])
|
stock, material = key
|
||||||
seed_pieces = list(seeds.get(stock, {}).values())
|
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)
|
is_sheet = (its and its[0].is_sheet) or (seed_pieces and seed_pieces[0].is_sheet)
|
||||||
if is_sheet:
|
if is_sheet:
|
||||||
sps, un = _pack_plywood_seeded(its, stock, s, ids, seed_pieces)
|
sps, un = _pack_plywood_seeded(its, stock, s, ids, seed_pieces)
|
||||||
else:
|
else:
|
||||||
sps, un = _pack_lumber_seeded(its, stock, s, ids, seed_pieces)
|
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
|
stock_pieces += sps
|
||||||
unplaced += un
|
unplaced += un
|
||||||
for iid in unplaced:
|
for iid in unplaced:
|
||||||
|
|
|
||||||
|
|
@ -242,12 +242,14 @@ class BomWindow(QDialog):
|
||||||
|
|
||||||
def _shop_text(self) -> str:
|
def _shop_text(self) -> str:
|
||||||
plan = self._plan
|
plan = self._plan
|
||||||
|
from ..lumber import default_material
|
||||||
lines = ["SHOPPING LIST", "", "Buy:"]
|
lines = ["SHOPPING LIST", "", "Buy:"]
|
||||||
bought = [sp for sp in plan.stock_pieces if not getattr(sp, "owned", False)]
|
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 ""
|
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}")
|
name = f"{mat} {stock}" if mat and mat != default_material(stock) else stock
|
||||||
|
lines.append(f" {qty} × {name} {unit}")
|
||||||
if not bought:
|
if not bought:
|
||||||
lines.append(" (nothing to buy)")
|
lines.append(" (nothing to buy)")
|
||||||
owned = [sp for sp in plan.stock_pieces if getattr(sp, "owned", False)]
|
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())
|
self._cost_te.setPlainText(self._cost_text())
|
||||||
|
|
||||||
def _edit_prices(self) -> None:
|
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():
|
if dlg.exec():
|
||||||
self._prices = dlg.prices()
|
self._prices = dlg.prices()
|
||||||
prices_mod.save_prices(self._prices)
|
prices_mod.save_prices(self._prices)
|
||||||
|
prices_mod.save_material_multipliers(dlg.multipliers())
|
||||||
self._cost_te.setPlainText(self._cost_text())
|
self._cost_te.setPlainText(self._cost_text())
|
||||||
|
|
||||||
def _refresh_prices(self) -> None:
|
def _refresh_prices(self) -> None:
|
||||||
|
|
@ -674,40 +678,54 @@ class BomWindow(QDialog):
|
||||||
|
|
||||||
|
|
||||||
class PriceEditDialog(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)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Edit prices")
|
self.setWindowTitle("Edit prices")
|
||||||
self.resize(360, 460)
|
self.resize(380, 560)
|
||||||
v = QVBoxLayout(self)
|
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."))
|
"Seeded from Kent NB — edit as needed."))
|
||||||
rows = sorted(prices.items())
|
self._table = self._make_table(prices, "Stock", "Price $", "{:.2f}")
|
||||||
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}"))
|
|
||||||
v.addWidget(self._table)
|
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 = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||||
bb.accepted.connect(self.accept)
|
bb.accepted.connect(self.accept)
|
||||||
bb.rejected.connect(self.reject)
|
bb.rejected.connect(self.reject)
|
||||||
v.addWidget(bb)
|
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 = {}
|
out = {}
|
||||||
for r in range(self._table.rowCount()):
|
for r in range(table.rowCount()):
|
||||||
stock = self._table.item(r, 0).text()
|
|
||||||
try:
|
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):
|
except (ValueError, AttributeError):
|
||||||
continue # skip blanked / bad cells
|
continue
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def prices(self) -> dict:
|
||||||
|
return self._read(self._table)
|
||||||
|
|
||||||
|
def multipliers(self) -> dict:
|
||||||
|
return self._read(self._mtable)
|
||||||
|
|
||||||
|
|
||||||
class RatesEditDialog(QDialog):
|
class RatesEditDialog(QDialog):
|
||||||
"""Edit labor rate, per-operation minutes, and consumable costs. Renders
|
"""Edit labor rate, per-operation minutes, and consumable costs. Renders
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ def plan_consumption(plan) -> tuple[dict, list[dict]]:
|
||||||
continue
|
continue
|
||||||
offcuts.append({
|
offcuts.append({
|
||||||
"stock": sp.stock,
|
"stock": sp.stock,
|
||||||
|
"material": getattr(sp, "material", ""),
|
||||||
"length_in": round(w.length_in, 2),
|
"length_in": round(w.length_in, 2),
|
||||||
"width_in": round(w.width_in or sp.width_in, 2),
|
"width_in": round(w.width_in or sp.width_in, 2),
|
||||||
"is_sheet": sp.is_sheet,
|
"is_sheet": sp.is_sheet,
|
||||||
|
|
@ -163,6 +164,7 @@ class Ledger:
|
||||||
del live[e["offcut_id"]]
|
del live[e["offcut_id"]]
|
||||||
return [Piece(id=oc["id"], stock=oc["stock"], length_in=oc["length_in"],
|
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,
|
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", ""))
|
source_project=oc.get("source_project", ""), bin=oc.get("bin", ""))
|
||||||
for oc in live.values()]
|
for oc in live.values()]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,39 @@ DEFAULT_PRICES: dict[str, float] = {
|
||||||
"ply-3/4": 63.98, # ✓ 3/4 4x8 spruce $63.98
|
"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 product pages for the refresh script (stock -> URL). Confirmed-real URLs.
|
||||||
KENT_URLS: dict[str, str] = {
|
KENT_URLS: dict[str, str] = {
|
||||||
"2x4": "https://kent.ca/en/2-x-4-x-8-spf-stud-kiln-dried-1016318",
|
"2x4": "https://kent.ca/en/2-x-4-x-8-spf-stud-kiln-dried-1016318",
|
||||||
|
|
@ -83,11 +116,19 @@ class CostLine:
|
||||||
qty: int
|
qty: int
|
||||||
unit_price: float | None # None = no price on file
|
unit_price: float | None # None = no price on file
|
||||||
unit_label: str
|
unit_label: str
|
||||||
|
material: str = "" # species this line was priced for
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total(self) -> float:
|
def total(self) -> float:
|
||||||
return round((self.unit_price or 0.0) * self.qty, 2)
|
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
|
@dataclass
|
||||||
class CostEstimate:
|
class CostEstimate:
|
||||||
|
|
@ -111,27 +152,31 @@ class CostEstimate:
|
||||||
return [ln.stock for ln in self.lines if ln.unit_price is None]
|
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:
|
def estimate(plan, prices: dict[str, float] | None = None, hst: float = NB_HST,
|
||||||
"""Cost of buying the stock a CutPlan calls for. Lumber prices scale with the
|
multipliers: dict[str, float] | None = None) -> CostEstimate:
|
||||||
plan's stick length (priced per foot off an 8' stick); plywood is per sheet."""
|
"""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
|
from collections import Counter
|
||||||
|
|
||||||
prices = prices if prices is not None else load_prices()
|
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
|
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
|
if not getattr(sp, "owned", False)) # offcuts are free, not bought
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for stock, qty in sorted(counts.items()):
|
for (stock, material), qty in sorted(counts.items()):
|
||||||
base = prices.get(stock)
|
base = prices.get(stock)
|
||||||
if base is None:
|
if base is None:
|
||||||
unit_price = None
|
unit_price = None
|
||||||
elif stock.startswith("ply-"):
|
|
||||||
unit_price = round(base, 2) # per sheet
|
|
||||||
else:
|
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,
|
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)
|
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:
|
for ln in est.lines:
|
||||||
price = _money(ln.unit_price) if ln.unit_price is not None else " — "
|
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 " — "
|
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}")
|
f"{price:>9} {total:>10}")
|
||||||
lines += [" " + "-" * 46,
|
lines += [" " + "-" * 46,
|
||||||
f" {'Subtotal':<38}{_money(est.subtotal):>10}",
|
f" {'Subtotal':<38}{_money(est.subtotal):>10}",
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,48 @@ def test_parse_price_reads_json_ld():
|
||||||
|
|
||||||
def test_parse_price_none_when_absent():
|
def test_parse_price_none_when_absent():
|
||||||
assert P._parse_price("<html><body>no price here</body></html>") is None
|
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"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue