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:
parent
c36ed3407e
commit
882b0ec959
|
|
@ -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()):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue