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;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue