Phase 2: finish costs by kind in the estimate

- EstimateRates.finish_cost_per_sqft and min_per_finish are now dicts keyed by
  finish kind (sanded/clear/stain/paint) — paint costs more and takes longer
  than a clear coat; all editable.
- project_estimate prices the finish line per part by its finish kind × surface
  area; finishing labor sums per-part by kind; raw parts cost nothing.
- load_rates generically merges any dict-valued rate field; RatesEditDialog
  rewritten to render scalars + dict sub-sections automatically.
- tests: finish cost varies by kind, raw = no finish line, dict roundtrip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 19:04:44 -03:00
parent c36ed3407e
commit 882b0ec959
3 changed files with 117 additions and 55 deletions

View File

@ -34,7 +34,9 @@ class EstimateRates:
# --- consumable unit costs ($) --- # --- consumable unit costs ($) ---
screw_unit_cost: float = 0.10 screw_unit_cost: float = 0.10
glue_cost_per_oz: float = 0.55 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 --- # --- consumable quantities ---
screws_per_butt_joint: float = 2.0 screws_per_butt_joint: float = 2.0
glue_oz_per_connection: float = 0.5 glue_oz_per_connection: float = 0.5
@ -44,7 +46,9 @@ class EstimateRates:
min_per_cut: float = 3.0 min_per_cut: float = 3.0
min_per_butt_joint: float = 5.0 min_per_butt_joint: float = 5.0
min_per_connection: float = 8.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: { min_per_feature: dict = field(default_factory=lambda: {
"tenon": 10.0, "mortise": 12.0, "hole": 2.0, "slot": 8.0, "tenon": 10.0, "mortise": 12.0, "hole": 2.0, "slot": 8.0,
"dado": 6.0, "rabbet": 6.0, "chamfer": 4.0}) "dado": 6.0, "rabbet": 6.0, "chamfer": 4.0})
@ -63,9 +67,11 @@ def load_rates() -> EstimateRates:
saved = json.loads(path.read_text()) saved = json.loads(path.read_text())
base = asdict(rates) base = asdict(rates)
for k, v in saved.items(): for k, v in saved.items():
if k == "min_per_feature" and isinstance(v, dict): if k not in base:
base["min_per_feature"].update({fk: float(fv) for fk, fv in v.items()}) continue
elif k in base and not isinstance(base[k], dict): 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) base[k] = float(v)
rates = EstimateRates(**base) rates = EstimateRates(**base)
except (ValueError, OSError, TypeError): except (ValueError, OSError, TypeError):
@ -95,15 +101,11 @@ def count_ops(scene, plan) -> dict:
} }
def _finished_sqft(scene) -> float: def _part_sqft(part) -> float:
total = 0.0 """Total surface area (all six faces) of a board, in sq ft."""
for p in scene.parts: t, w = part.section_in
if p.finish == "raw": L = part.length_in
continue return 2 * (L * w + L * t + w * t) / 144.0
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
@dataclass @dataclass
@ -174,10 +176,18 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
if glue_oz: if glue_oz:
consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/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))) round(glue_oz * rates.glue_cost_per_oz, 2)))
sqft = _finished_sqft(scene) # finish material: per part, priced by its finish kind × surface area
if sqft: finish_cost, finish_sqft, kinds = 0.0, 0.0, set()
consumables.append(Line("Finish", f"{sqft:.1f} sq ft × ${rates.finish_cost_per_sqft:.2f}", for p in scene.parts:
round(sqft * rates.finish_cost_per_sqft, 2))) 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) --- # --- labor (minutes -> cost) ---
def line(label, minutes, n_detail): 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)"), f"{ops['butt_joints']} joint(s)"),
("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection, ("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection,
f"{ops['connections']} connection(s)"), 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)"), f"{ops['finished_parts']} part(s)"),
] ]
for kind, n in sorted(ops["features"].items()): for kind, n in sorted(ops["features"].items()):

View File

@ -600,57 +600,70 @@ class PriceEditDialog(QDialog):
class RatesEditDialog(QDialog): class RatesEditDialog(QDialog):
"""Edit labor rate, per-operation minutes, and consumable costs.""" """Edit labor rate, per-operation minutes, and consumable costs. Renders
scalar rate fields as spin boxes and dict fields (per-feature time, finish
# field -> (label, suffix, step) cost/time by kind) as labelled sub-sections generic over EstimateRates."""
_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),
]
def __init__(self, rates, parent=None): def __init__(self, rates, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Edit rates") self.setWindowTitle("Edit rates")
self.resize(380, 560) self.resize(400, 600)
self._rates = rates self._rates = rates
self._spins = {} self._spins = {} # field -> spin (scalars)
self._feat_spins = {} self._dict_spins = {} # field -> {key -> spin}
from dataclasses import asdict
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
area = QScrollArea(); area.setWidgetResizable(True) area = QScrollArea(); area.setWidgetResizable(True)
body = QWidget(); form = QFormLayout(body) body = QWidget(); form = QFormLayout(body)
for field, label, suffix, step in self._SCALARS: for field, val in asdict(rates).items():
sp = QDoubleSpinBox() if isinstance(val, dict):
sp.setRange(0.0, 100000.0); sp.setSingleStep(step); sp.setSuffix(suffix) form.addRow(QLabel(f"{self._pretty(field)}"))
sp.setValue(float(getattr(rates, field))) self._dict_spins[field] = {}
self._spins[field] = sp for key, v in sorted(val.items()):
form.addRow(label, sp) sp = self._spin(field, v)
form.addRow(QLabel("— Joinery time (minutes each) —")) self._dict_spins[field][key] = sp
for kind, minutes in sorted(rates.min_per_feature.items()): form.addRow(key, sp)
sp = QDoubleSpinBox() else:
sp.setRange(0.0, 1000.0); sp.setSingleStep(0.5); sp.setSuffix(" min") sp = self._spin(field, val)
sp.setValue(float(minutes)) self._spins[field] = sp
self._feat_spins[kind] = sp form.addRow(self._pretty(field), sp)
form.addRow(kind, sp)
area.setWidget(body) area.setWidget(body)
outer.addWidget(area) outer.addWidget(area)
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject) bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
outer.addWidget(bb) 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): def rates(self):
for field, sp in self._spins.items(): for field, sp in self._spins.items():
setattr(self._rates, field, sp.value()) setattr(self._rates, field, sp.value())
for kind, sp in self._feat_spins.items(): for field, spins in self._dict_spins.items():
self._rates.min_per_feature[kind] = sp.value() d = getattr(self._rates, field)
for key, sp in spins.items():
d[key] = sp.value()
return self._rates return self._rates

View File

@ -46,7 +46,7 @@ def test_labor_scales_with_rate_and_ops():
s.place("2x4", 24) s.place("2x4", 24)
plan = build_cut_plan(s) plan = build_cut_plan(s)
rates = E.EstimateRates(labor_rate_per_hr=60.0, setup_min=0, min_per_cut=10, 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) est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
# 1 cut × 10 min = 10 min = 1/6 h × $60 = $10 # 1 cut × 10 min = 10 min = 1/6 h × $60 = $10
assert est.labor_minutes == 10.0 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})) text = E.format_estimate(E.project_estimate(s, plan, prices={"2x4": 4.0}))
for token in ("Materials", "Labor", "TOTAL COST", "SUGGESTED PRICE"): for token in ("Materials", "Labor", "TOTAL COST", "SUGGESTED PRICE"):
assert token in text 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