diff --git a/src/woodshop/estimate.py b/src/woodshop/estimate.py index 546c02c..1ba4518 100644 --- a/src/woodshop/estimate.py +++ b/src/woodshop/estimate.py @@ -34,7 +34,9 @@ class EstimateRates: # --- consumable unit costs ($) --- screw_unit_cost: float = 0.10 glue_cost_per_oz: float = 0.55 - finish_cost_per_sqft: float = 0.40 + # finish material $/sq ft, by finish kind (sanded = abrasives only) + finish_cost_per_sqft: dict = field(default_factory=lambda: { + "sanded": 0.05, "clear": 0.35, "stain": 0.45, "paint": 0.60}) # --- consumable quantities --- screws_per_butt_joint: float = 2.0 glue_oz_per_connection: float = 0.5 @@ -44,7 +46,9 @@ class EstimateRates: min_per_cut: float = 3.0 min_per_butt_joint: float = 5.0 min_per_connection: float = 8.0 - min_per_finish: float = 10.0 + # finishing time per part, by finish kind (paint/stain take longer) + min_per_finish: dict = field(default_factory=lambda: { + "sanded": 8.0, "clear": 12.0, "stain": 14.0, "paint": 16.0}) min_per_feature: dict = field(default_factory=lambda: { "tenon": 10.0, "mortise": 12.0, "hole": 2.0, "slot": 8.0, "dado": 6.0, "rabbet": 6.0, "chamfer": 4.0}) @@ -63,9 +67,11 @@ def load_rates() -> EstimateRates: saved = json.loads(path.read_text()) base = asdict(rates) for k, v in saved.items(): - if k == "min_per_feature" and isinstance(v, dict): - base["min_per_feature"].update({fk: float(fv) for fk, fv in v.items()}) - elif k in base and not isinstance(base[k], dict): + if k not in base: + continue + if isinstance(base[k], dict) and isinstance(v, dict): + base[k].update({fk: float(fv) for fk, fv in v.items()}) + elif not isinstance(base[k], dict): base[k] = float(v) rates = EstimateRates(**base) except (ValueError, OSError, TypeError): @@ -95,15 +101,11 @@ def count_ops(scene, plan) -> dict: } -def _finished_sqft(scene) -> float: - total = 0.0 - for p in scene.parts: - if p.finish == "raw": - continue - t, w = p.section_in - L = p.length_in - total += 2 * (L * w + L * t + w * t) / 144.0 # all six faces, sq ft - return total +def _part_sqft(part) -> float: + """Total surface area (all six faces) of a board, in sq ft.""" + t, w = part.section_in + L = part.length_in + return 2 * (L * w + L * t + w * t) / 144.0 @dataclass @@ -174,10 +176,18 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non if glue_oz: consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/oz", round(glue_oz * rates.glue_cost_per_oz, 2))) - sqft = _finished_sqft(scene) - if sqft: - consumables.append(Line("Finish", f"{sqft:.1f} sq ft × ${rates.finish_cost_per_sqft:.2f}", - round(sqft * rates.finish_cost_per_sqft, 2))) + # finish material: per part, priced by its finish kind × surface area + finish_cost, finish_sqft, kinds = 0.0, 0.0, set() + for p in scene.parts: + if p.finish == "raw": + continue + a = _part_sqft(p) + finish_sqft += a + finish_cost += a * rates.finish_cost_per_sqft.get(p.finish, 0.0) + kinds.add(p.finish) + if finish_cost > 0: + consumables.append(Line("Finish", f"{'/'.join(sorted(kinds))} · {finish_sqft:.1f} sq ft", + round(finish_cost, 2))) # --- labor (minutes -> cost) --- def line(label, minutes, n_detail): @@ -191,7 +201,8 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non f"{ops['butt_joints']} joint(s)"), ("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection, f"{ops['connections']} connection(s)"), - ("Sanding / finishing", ops["finished_parts"] * rates.min_per_finish, + ("Sanding / finishing", + sum(rates.min_per_finish.get(p.finish, 0.0) for p in scene.parts if p.finish != "raw"), f"{ops['finished_parts']} part(s)"), ] for kind, n in sorted(ops["features"].items()): diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 32dad1f..4abc597 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -600,57 +600,70 @@ class PriceEditDialog(QDialog): class RatesEditDialog(QDialog): - """Edit labor rate, per-operation minutes, and consumable costs.""" - - # field -> (label, suffix, step) - _SCALARS = [ - ("labor_rate_per_hr", "Labor rate", " $/h", 1.0), - ("setup_min", "Setup / cleanup", " min", 1.0), - ("min_per_cut", "Time per cut", " min", 0.5), - ("min_per_butt_joint", "Time per butt joint", " min", 0.5), - ("min_per_connection", "Time per assembly (M&T)", " min", 0.5), - ("min_per_finish", "Time per part sanded", " min", 0.5), - ("screws_per_butt_joint", "Screws per butt joint", "", 1.0), - ("screw_unit_cost", "Screw cost", " $", 0.01), - ("glue_oz_per_connection", "Glue per assembly", " oz", 0.1), - ("glue_oz_per_glued_feature", "Glue per dado/rabbet", " oz", 0.1), - ("glue_cost_per_oz", "Glue cost", " $/oz", 0.05), - ("finish_cost_per_sqft", "Finish cost", " $/sq ft", 0.05), - ] + """Edit labor rate, per-operation minutes, and consumable costs. Renders + scalar rate fields as spin boxes and dict fields (per-feature time, finish + cost/time by kind) as labelled sub-sections — generic over EstimateRates.""" def __init__(self, rates, parent=None): super().__init__(parent) self.setWindowTitle("Edit rates") - self.resize(380, 560) + self.resize(400, 600) self._rates = rates - self._spins = {} - self._feat_spins = {} + self._spins = {} # field -> spin (scalars) + self._dict_spins = {} # field -> {key -> spin} + from dataclasses import asdict outer = QVBoxLayout(self) area = QScrollArea(); area.setWidgetResizable(True) body = QWidget(); form = QFormLayout(body) - for field, label, suffix, step in self._SCALARS: - sp = QDoubleSpinBox() - sp.setRange(0.0, 100000.0); sp.setSingleStep(step); sp.setSuffix(suffix) - sp.setValue(float(getattr(rates, field))) - self._spins[field] = sp - form.addRow(label, sp) - form.addRow(QLabel("— Joinery time (minutes each) —")) - for kind, minutes in sorted(rates.min_per_feature.items()): - sp = QDoubleSpinBox() - sp.setRange(0.0, 1000.0); sp.setSingleStep(0.5); sp.setSuffix(" min") - sp.setValue(float(minutes)) - self._feat_spins[kind] = sp - form.addRow(kind, sp) + for field, val in asdict(rates).items(): + if isinstance(val, dict): + form.addRow(QLabel(f"— {self._pretty(field)} —")) + self._dict_spins[field] = {} + for key, v in sorted(val.items()): + sp = self._spin(field, v) + self._dict_spins[field][key] = sp + form.addRow(key, sp) + else: + sp = self._spin(field, val) + self._spins[field] = sp + form.addRow(self._pretty(field), sp) area.setWidget(body) outer.addWidget(area) bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) bb.accepted.connect(self.accept); bb.rejected.connect(self.reject) outer.addWidget(bb) + @staticmethod + def _pretty(field: str) -> str: + return field.replace("min_per_", "time: ").replace("_", " ") + + @staticmethod + def _suffix(field: str) -> str: + if "pct" in field: + return " %" + if "min_per" in field or field.endswith("_min"): + return " min" + if "oz" in field and "cost" not in field: + return " oz" + if any(t in field for t in ("cost", "rate", "price")): + return " $" + return "" + + def _spin(self, field, value): + sp = QDoubleSpinBox() + sp.setRange(0.0, 1_000_000.0) + sp.setDecimals(2) + sp.setSingleStep(0.5 if "min" in field else 0.05) + sp.setSuffix(self._suffix(field)) + sp.setValue(float(value)) + return sp + def rates(self): for field, sp in self._spins.items(): setattr(self._rates, field, sp.value()) - for kind, sp in self._feat_spins.items(): - self._rates.min_per_feature[kind] = sp.value() + for field, spins in self._dict_spins.items(): + d = getattr(self._rates, field) + for key, sp in spins.items(): + d[key] = sp.value() return self._rates diff --git a/tests/test_estimate.py b/tests/test_estimate.py index 02025b4..619ae1d 100644 --- a/tests/test_estimate.py +++ b/tests/test_estimate.py @@ -46,7 +46,7 @@ def test_labor_scales_with_rate_and_ops(): s.place("2x4", 24) plan = build_cut_plan(s) rates = E.EstimateRates(labor_rate_per_hr=60.0, setup_min=0, min_per_cut=10, - min_per_butt_joint=0, min_per_connection=0, min_per_finish=0) + min_per_butt_joint=0, min_per_connection=0) est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates) # 1 cut × 10 min = 10 min = 1/6 h × $60 = $10 assert est.labor_minutes == 10.0 @@ -106,3 +106,41 @@ def test_format_includes_all_sections(): text = E.format_estimate(E.project_estimate(s, plan, prices={"2x4": 4.0})) for token in ("Materials", "Labor", "TOTAL COST", "SUGGESTED PRICE"): assert token in text + + +def test_finish_cost_varies_by_kind(): + from woodshop.scene import Scene + s = Scene() + s.place("2x4", 24) + s.set_finish("p1", "paint") + plan = build_cut_plan(s) + rates = E.EstimateRates() + est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates) + fin = next(l for l in est.consumables if l.label == "Finish") + sqft = E._part_sqft(s.get_part("p1")) + assert abs(fin.cost - round(sqft * rates.finish_cost_per_sqft["paint"], 2)) < 0.01 + # paint costs more than clear for the same part + s.set_finish("p1", "clear") + est2 = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates) + fin2 = next(l for l in est2.consumables if l.label == "Finish") + assert fin2.cost < fin.cost + + +def test_raw_parts_have_no_finish_cost(): + from woodshop.scene import Scene + s = Scene() + s.place("2x4", 24) # raw + plan = build_cut_plan(s) + est = E.project_estimate(s, plan, prices={"2x4": 4.0}) + assert not any(l.label == "Finish" for l in est.consumables) + + +def test_finish_rates_dict_roundtrips(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + r = E.EstimateRates() + r.finish_cost_per_sqft["paint"] = 1.23 + r.min_per_finish["paint"] = 99.0 + E.save_rates(r) + loaded = E.load_rates() + assert loaded.finish_cost_per_sqft["paint"] == 1.23 + assert loaded.min_per_finish["paint"] == 99.0