Add project estimate: consumables, labor, and suggested selling price
The Cost tab now produces a full quote, not just material cost. - estimate.py: project_estimate() = materials (incl HST) + consumables (screws per butt joint, glue per M&T connection / dado / rabbet, finish $/sq ft of finished surface) + labor (editable minutes per operation — setup, cut, butt joint, assembly, sanding, and per-feature tenon/mortise/ hole/slot/dado/rabbet/chamfer — × counts from the scene/plan, × shop rate). - Selling price = MARGIN on total cost: price = total_cost / (1 - margin), labor counted as cost. A target price overrides margin and back-solves the implied margin. EstimateRates persisted to estimate.json. - Cost tab: live margin % spinbox + target $ field, "Edit rates…" (RatesEditDialog), existing "Edit prices…" / "Refresh from Kent…", Print. - All counts are deterministic (count_ops off scene.joints / connections / features / finishes); nothing guessed. - tests: op counts, screws/glue, labor scaling, margin formula, target back-solve, div-zero guard, rates roundtrip, format, and GUI cost-tab + margin/target controls. 163 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
067ec0ea46
commit
30bfb3a9e0
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""Project estimate: materials + consumables + labor, then a suggested selling
|
||||||
|
price from an editable profit margin (or a target price that back-solves it).
|
||||||
|
|
||||||
|
Like the rest of the shop packet this is a deterministic math layer: every
|
||||||
|
number is counted off the scene/CutPlan and multiplied by an editable rate.
|
||||||
|
Nothing is guessed — the rates below are sensible starting points, all editable
|
||||||
|
in the app (Cost tab → "Edit rates…") and persisted per machine.
|
||||||
|
|
||||||
|
Selling-price model (Rob's choice): MARGIN on total cost. Labor at an editable
|
||||||
|
shop rate is a cost alongside materials and consumables; the margin is profit on
|
||||||
|
top: price = total_cost / (1 − margin). Set labor_rate to 0 to exclude labor.
|
||||||
|
A target price, if given, overrides margin and we report the implied margin.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import prices as prices_mod
|
||||||
|
|
||||||
|
# Feature kinds that imply a glue-up not already captured as a Connection
|
||||||
|
# (tenon↔mortise mates are counted via scene.connections, so exclude them here).
|
||||||
|
GLUED_FEATURE_KINDS = {"dado", "rabbet", "slot"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EstimateRates:
|
||||||
|
# --- selling price ---
|
||||||
|
margin_pct: float = 30.0 # profit as a % of price; price = cost/(1-margin)
|
||||||
|
target_price: float = 0.0 # >0 overrides margin; back-solves implied margin
|
||||||
|
labor_rate_per_hr: float = 40.0
|
||||||
|
# --- consumable unit costs ($) ---
|
||||||
|
screw_unit_cost: float = 0.10
|
||||||
|
glue_cost_per_oz: float = 0.55
|
||||||
|
finish_cost_per_sqft: float = 0.40
|
||||||
|
# --- consumable quantities ---
|
||||||
|
screws_per_butt_joint: float = 2.0
|
||||||
|
glue_oz_per_connection: float = 0.5
|
||||||
|
glue_oz_per_glued_feature: float = 0.25
|
||||||
|
# --- time (minutes) ---
|
||||||
|
setup_min: float = 30.0
|
||||||
|
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
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path() -> Path:
|
||||||
|
base = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "woodshop"
|
||||||
|
return base / "estimate.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_rates() -> EstimateRates:
|
||||||
|
rates = EstimateRates()
|
||||||
|
path = _config_path()
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
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):
|
||||||
|
base[k] = float(v)
|
||||||
|
rates = EstimateRates(**base)
|
||||||
|
except (ValueError, OSError, TypeError):
|
||||||
|
pass
|
||||||
|
return rates
|
||||||
|
|
||||||
|
|
||||||
|
def save_rates(rates: EstimateRates) -> None:
|
||||||
|
path = _config_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(asdict(rates), indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def count_ops(scene, plan) -> dict:
|
||||||
|
"""Deterministic operation counts off the scene + cut plan."""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
feats = Counter(f.kind for p in scene.parts for f in p.features)
|
||||||
|
return {
|
||||||
|
"parts": len(scene.parts),
|
||||||
|
"cuts": len(plan.items), # one crosscut per cut item
|
||||||
|
"butt_joints": len(scene.joints),
|
||||||
|
"connections": len(scene.connections),
|
||||||
|
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS),
|
||||||
|
"finished_parts": sum(1 for p in scene.parts if p.finishes),
|
||||||
|
"features": dict(feats),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _finished_sqft(scene) -> float:
|
||||||
|
total = 0.0
|
||||||
|
for p in scene.parts:
|
||||||
|
if not p.finishes:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Line:
|
||||||
|
label: str
|
||||||
|
detail: str
|
||||||
|
cost: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectEstimate:
|
||||||
|
material: object # prices.CostEstimate
|
||||||
|
consumables: list # list[Line]
|
||||||
|
labor_lines: list # list[Line] (time breakdown, cost per group)
|
||||||
|
labor_minutes: float
|
||||||
|
rates: EstimateRates
|
||||||
|
|
||||||
|
@property
|
||||||
|
def material_cost(self) -> float:
|
||||||
|
return self.material.total # includes HST — what you pay
|
||||||
|
|
||||||
|
@property
|
||||||
|
def consumable_cost(self) -> float:
|
||||||
|
return round(sum(l.cost for l in self.consumables), 2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def labor_cost(self) -> float:
|
||||||
|
return round(sum(l.cost for l in self.labor_lines), 2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_cost(self) -> float:
|
||||||
|
return round(self.material_cost + self.consumable_cost + self.labor_cost, 2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_margin(self) -> float:
|
||||||
|
"""Margin actually used: from target_price if set, else rates.margin_pct."""
|
||||||
|
if self.rates.target_price and self.rates.target_price > 0:
|
||||||
|
price = self.rates.target_price
|
||||||
|
return round((price - self.total_cost) / price * 100, 1) if price else 0.0
|
||||||
|
return self.rates.margin_pct
|
||||||
|
|
||||||
|
@property
|
||||||
|
def price(self) -> float:
|
||||||
|
if self.rates.target_price and self.rates.target_price > 0:
|
||||||
|
return round(self.rates.target_price, 2)
|
||||||
|
m = min(max(self.rates.margin_pct, 0.0), 95.0) / 100.0 # guard /0
|
||||||
|
return round(self.total_cost / (1 - m), 2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profit(self) -> float:
|
||||||
|
return round(self.price - self.total_cost, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None,
|
||||||
|
hst: float = prices_mod.NB_HST) -> ProjectEstimate:
|
||||||
|
rates = rates or load_rates()
|
||||||
|
ops = count_ops(scene, plan)
|
||||||
|
material = prices_mod.estimate(plan, prices, hst=hst)
|
||||||
|
|
||||||
|
# --- consumables ---
|
||||||
|
consumables = []
|
||||||
|
screws = ops["butt_joints"] * rates.screws_per_butt_joint
|
||||||
|
if screws:
|
||||||
|
consumables.append(Line("Screws", f"{screws:g} × ${rates.screw_unit_cost:.2f}",
|
||||||
|
round(screws * rates.screw_unit_cost, 2)))
|
||||||
|
glue_oz = (ops["connections"] * rates.glue_oz_per_connection
|
||||||
|
+ ops["glued_features"] * rates.glue_oz_per_glued_feature)
|
||||||
|
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)))
|
||||||
|
|
||||||
|
# --- labor (minutes -> cost) ---
|
||||||
|
def line(label, minutes, n_detail):
|
||||||
|
return Line(label, n_detail, round(minutes / 60.0 * rates.labor_rate_per_hr, 2)), minutes
|
||||||
|
|
||||||
|
labor_lines, total_min = [], 0.0
|
||||||
|
groups = [
|
||||||
|
("Setup / cleanup", rates.setup_min if ops["parts"] else 0.0, "fixed"),
|
||||||
|
("Cutting to length", ops["cuts"] * rates.min_per_cut, f"{ops['cuts']} cut(s)"),
|
||||||
|
("Butt joints", ops["butt_joints"] * rates.min_per_butt_joint,
|
||||||
|
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,
|
||||||
|
f"{ops['finished_parts']} part(s)"),
|
||||||
|
]
|
||||||
|
for kind, n in sorted(ops["features"].items()):
|
||||||
|
groups.append((f"Joinery: {kind}", n * rates.min_per_feature.get(kind, 5.0),
|
||||||
|
f"{n} × {kind}"))
|
||||||
|
for label, minutes, detail in groups:
|
||||||
|
if minutes > 0:
|
||||||
|
ln, m = line(label, minutes, detail)
|
||||||
|
labor_lines.append(ln)
|
||||||
|
total_min += m
|
||||||
|
|
||||||
|
return ProjectEstimate(material=material, consumables=consumables,
|
||||||
|
labor_lines=labor_lines, labor_minutes=round(total_min, 1),
|
||||||
|
rates=rates)
|
||||||
|
|
||||||
|
|
||||||
|
def _money(v: float) -> str:
|
||||||
|
return f"${v:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
|
||||||
|
hrs = est.labor_minutes / 60.0
|
||||||
|
lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%)",
|
||||||
|
"editable estimate — verify before quoting", ""]
|
||||||
|
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
|
||||||
|
for l in est.consumables:
|
||||||
|
lines.append(f" {l.label:<14} {l.detail:<22}{_money(l.cost):>10}")
|
||||||
|
if est.consumables:
|
||||||
|
lines.append(f" {'Consumables':<30}{_money(est.consumable_cost):>12}")
|
||||||
|
lines.append("")
|
||||||
|
for l in est.labor_lines:
|
||||||
|
lines.append(f" {l.label:<22} {l.detail:<16}{_money(l.cost):>10}")
|
||||||
|
lines.append(f" {'Labor (' + f'{hrs:.1f}h @ ' + _money(est.rates.labor_rate_per_hr) + '/h)':<30}"
|
||||||
|
f"{_money(est.labor_cost):>12}")
|
||||||
|
lines += [" " + "-" * 42,
|
||||||
|
f" {'TOTAL COST':<30}{_money(est.total_cost):>12}"]
|
||||||
|
if est.rates.target_price and est.rates.target_price > 0:
|
||||||
|
lines.append(f" {'Target price (margin ' + f'{est.effective_margin:g}%)':<30}"
|
||||||
|
f"{_money(est.price):>12}")
|
||||||
|
else:
|
||||||
|
lines.append(f" {'Margin (' + f'{est.rates.margin_pct:g}%)':<30}{_money(est.profit):>12}")
|
||||||
|
lines += [" " + "=" * 42,
|
||||||
|
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
|
||||||
|
f" {'(profit ' + _money(est.profit) + ')':<30}"]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
@ -8,9 +8,10 @@ import subprocess
|
||||||
from PySide6.QtCore import Qt, QThreadPool
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||||
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
||||||
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QGraphicsItem, QGraphicsRectItem,
|
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout,
|
||||||
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
|
QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
|
||||||
QHBoxLayout, QHeaderView, QLabel, QMenu, QPushButton,
|
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
|
||||||
|
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
|
||||||
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
|
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
|
||||||
QVBoxLayout, QWidget)
|
QVBoxLayout, QWidget)
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement
|
||||||
from ..instructions import build_steps, format_steps, polish_prompt
|
from ..instructions import build_steps, format_steps, polish_prompt
|
||||||
from ..jigs import explain_prompt, format_jigs, suggest_jigs
|
from ..jigs import explain_prompt, format_jigs, suggest_jigs
|
||||||
from .. import prices as prices_mod
|
from .. import prices as prices_mod
|
||||||
|
from .. import estimate as estimate_mod
|
||||||
from .workers import run_async
|
from .workers import run_async
|
||||||
|
|
||||||
_PX = 7.0 # pixels per inch in the layout view
|
_PX = 7.0 # pixels per inch in the layout view
|
||||||
|
|
@ -78,6 +80,7 @@ class BomWindow(QDialog):
|
||||||
self.pool = QThreadPool.globalInstance()
|
self.pool = QThreadPool.globalInstance()
|
||||||
|
|
||||||
self._prices = prices_mod.load_prices()
|
self._prices = prices_mod.load_prices()
|
||||||
|
self._rates = estimate_mod.load_rates()
|
||||||
self._cut_te = self._mono_te()
|
self._cut_te = self._mono_te()
|
||||||
self._shop_te = self._mono_te()
|
self._shop_te = self._mono_te()
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
|
|
@ -173,21 +176,72 @@ class BomWindow(QDialog):
|
||||||
v = QVBoxLayout(w)
|
v = QVBoxLayout(w)
|
||||||
self._cost_te = self._mono_te()
|
self._cost_te = self._mono_te()
|
||||||
v.addWidget(self._cost_te)
|
v.addWidget(self._cost_te)
|
||||||
|
|
||||||
|
# live price controls: margin % (or a target $ that overrides it)
|
||||||
|
pricing = QHBoxLayout()
|
||||||
|
pricing.addWidget(QLabel("Margin %"))
|
||||||
|
self._margin_spin = QDoubleSpinBox()
|
||||||
|
self._margin_spin.setRange(0.0, 95.0)
|
||||||
|
self._margin_spin.setValue(self._rates.margin_pct)
|
||||||
|
self._margin_spin.valueChanged.connect(self._on_margin_changed)
|
||||||
|
pricing.addWidget(self._margin_spin)
|
||||||
|
pricing.addSpacing(12)
|
||||||
|
pricing.addWidget(QLabel("or target $"))
|
||||||
|
self._target_spin = QDoubleSpinBox()
|
||||||
|
self._target_spin.setRange(0.0, 1_000_000.0)
|
||||||
|
self._target_spin.setDecimals(2)
|
||||||
|
self._target_spin.setSpecialValueText("—") # 0 shows as "—" (use margin)
|
||||||
|
self._target_spin.setValue(self._rates.target_price)
|
||||||
|
self._target_spin.valueChanged.connect(self._on_target_changed)
|
||||||
|
pricing.addWidget(self._target_spin)
|
||||||
|
pricing.addStretch()
|
||||||
|
v.addLayout(pricing)
|
||||||
|
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
edit = QPushButton("Edit prices…")
|
edit = QPushButton("Edit prices…")
|
||||||
edit.clicked.connect(self._edit_prices)
|
edit.clicked.connect(self._edit_prices)
|
||||||
|
rates = QPushButton("Edit rates…")
|
||||||
|
rates.setToolTip("Labor rate, time per operation, and consumable costs")
|
||||||
|
rates.clicked.connect(self._edit_rates)
|
||||||
refresh = QPushButton("Refresh from Kent…")
|
refresh = QPushButton("Refresh from Kent…")
|
||||||
refresh.setToolTip("Best-effort fetch of current Kent NB prices "
|
refresh.setToolTip("Best-effort fetch of current Kent NB prices "
|
||||||
"(needs a headless browser; may need updating if Kent changes)")
|
"(needs a headless browser; may need updating if Kent changes)")
|
||||||
refresh.clicked.connect(self._refresh_prices)
|
refresh.clicked.connect(self._refresh_prices)
|
||||||
pr = QPushButton("Print…")
|
pr = QPushButton("Print…")
|
||||||
pr.clicked.connect(lambda: self._print_text(self._cost_te))
|
pr.clicked.connect(lambda: self._print_text(self._cost_te))
|
||||||
row.addWidget(edit); row.addWidget(refresh); row.addStretch(); row.addWidget(pr)
|
row.addWidget(edit); row.addWidget(rates); row.addWidget(refresh)
|
||||||
|
row.addStretch(); row.addWidget(pr)
|
||||||
v.addLayout(row)
|
v.addLayout(row)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
def _cost_text(self) -> str:
|
def _cost_text(self) -> str:
|
||||||
return prices_mod.format_estimate(prices_mod.estimate(self._plan, self._prices))
|
est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, self._rates)
|
||||||
|
return estimate_mod.format_estimate(est)
|
||||||
|
|
||||||
|
def _on_margin_changed(self, value: float) -> None:
|
||||||
|
self._rates.margin_pct = value
|
||||||
|
if value and self._target_spin.value(): # margin wins; clear any target
|
||||||
|
self._target_spin.blockSignals(True)
|
||||||
|
self._target_spin.setValue(0.0)
|
||||||
|
self._target_spin.blockSignals(False)
|
||||||
|
self._rates.target_price = 0.0
|
||||||
|
estimate_mod.save_rates(self._rates)
|
||||||
|
self._cost_te.setPlainText(self._cost_text())
|
||||||
|
|
||||||
|
def _on_target_changed(self, value: float) -> None:
|
||||||
|
self._rates.target_price = value
|
||||||
|
estimate_mod.save_rates(self._rates)
|
||||||
|
self._cost_te.setPlainText(self._cost_text())
|
||||||
|
|
||||||
|
def _edit_rates(self) -> None:
|
||||||
|
dlg = RatesEditDialog(self._rates, self)
|
||||||
|
if dlg.exec():
|
||||||
|
self._rates = dlg.rates()
|
||||||
|
estimate_mod.save_rates(self._rates)
|
||||||
|
self._margin_spin.blockSignals(True)
|
||||||
|
self._margin_spin.setValue(self._rates.margin_pct)
|
||||||
|
self._margin_spin.blockSignals(False)
|
||||||
|
self._cost_te.setPlainText(self._cost_text())
|
||||||
|
|
||||||
def _edit_prices(self) -> None:
|
def _edit_prices(self) -> None:
|
||||||
dlg = PriceEditDialog(self._prices, self)
|
dlg = PriceEditDialog(self._prices, self)
|
||||||
|
|
@ -543,3 +597,60 @@ class PriceEditDialog(QDialog):
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
continue # skip blanked / bad cells
|
continue # skip blanked / bad cells
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, rates, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Edit rates")
|
||||||
|
self.resize(380, 560)
|
||||||
|
self._rates = rates
|
||||||
|
self._spins = {}
|
||||||
|
self._feat_spins = {}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
return self._rates
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,28 @@ def test_cost_tab_renders_estimate(tmp_path, monkeypatch):
|
||||||
c.place("2x4", 40)
|
c.place("2x4", 40)
|
||||||
w = BomWindow(c)
|
w = BomWindow(c)
|
||||||
text = w._cost_te.toPlainText()
|
text = w._cost_te.toPlainText()
|
||||||
assert "COST ESTIMATE" in text and "2x4" in text and "Total" in text
|
assert "PROJECT ESTIMATE" in text and "TOTAL COST" in text and "SUGGESTED PRICE" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_margin_spin_updates_price_and_persists(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
|
||||||
|
from woodshop import estimate as E
|
||||||
|
c = Controller(str(tmp_path / "s.json"))
|
||||||
|
c.place("2x4", 40)
|
||||||
|
w = BomWindow(c)
|
||||||
|
w._margin_spin.setValue(50.0) # fires _on_margin_changed
|
||||||
|
assert "50%" in w._cost_te.toPlainText()
|
||||||
|
assert E.load_rates().margin_pct == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_overrides_margin(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
|
||||||
|
c = Controller(str(tmp_path / "s.json"))
|
||||||
|
c.place("2x4", 40)
|
||||||
|
w = BomWindow(c)
|
||||||
|
w._target_spin.setValue(500.0)
|
||||||
|
assert "Target price" in w._cost_te.toPlainText()
|
||||||
|
assert "$500.00" in w._cost_te.toPlainText()
|
||||||
|
|
||||||
|
|
||||||
def test_edit_prices_persists_and_updates(tmp_path, monkeypatch):
|
def test_edit_prices_persists_and_updates(tmp_path, monkeypatch):
|
||||||
|
|
@ -74,11 +95,11 @@ def test_edit_prices_persists_and_updates(tmp_path, monkeypatch):
|
||||||
c = Controller(str(tmp_path / "s.json"))
|
c = Controller(str(tmp_path / "s.json"))
|
||||||
c.place("2x4", 40)
|
c.place("2x4", 40)
|
||||||
w = BomWindow(c)
|
w = BomWindow(c)
|
||||||
w._prices["2x4"] = 9.99 # simulate an edit accepted from the dialog
|
w._prices["2x4"] = 10.00 # simulate an edit accepted from the dialog
|
||||||
P.save_prices(w._prices)
|
P.save_prices(w._prices)
|
||||||
w._cost_te.setPlainText(w._cost_text())
|
w._cost_te.setPlainText(w._cost_text())
|
||||||
assert "$9.99" in w._cost_te.toPlainText()
|
assert "$11.50" in w._cost_te.toPlainText() # 1 stick × $10 + 15% HST
|
||||||
assert P.load_prices()["2x4"] == 9.99
|
assert P.load_prices()["2x4"] == 10.00
|
||||||
|
|
||||||
|
|
||||||
def test_best_of_n_with_lock_runs_and_validates(tmp_path):
|
def test_best_of_n_with_lock_runs_and_validates(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Tests for the project estimate (consumables + labor + selling price)."""
|
||||||
|
from woodshop import estimate as E
|
||||||
|
from woodshop import prices as P
|
||||||
|
from woodshop.cutplan import build_cut_plan
|
||||||
|
from woodshop.scene import Scene
|
||||||
|
|
||||||
|
|
||||||
|
def _table_scene():
|
||||||
|
"""A tiny table-ish scene: 4 legs + 4 aprons, joined, some joinery."""
|
||||||
|
s = Scene()
|
||||||
|
for _ in range(4):
|
||||||
|
s.place("2x4", 28) # legs
|
||||||
|
for _ in range(4):
|
||||||
|
s.place("2x4", 24) # aprons
|
||||||
|
# join aprons to legs (butt joins)
|
||||||
|
for apron in ("p5", "p6", "p7", "p8"):
|
||||||
|
s.join(apron, "p1")
|
||||||
|
s.add_feature("p5", "tenon", face="end_b", depth_in=1)
|
||||||
|
s.add_feature("p1", "mortise", face="left")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def test_counts_match_scene():
|
||||||
|
s = _table_scene()
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
ops = E.count_ops(s, plan)
|
||||||
|
assert ops["parts"] == 8
|
||||||
|
assert ops["butt_joints"] == 4
|
||||||
|
assert ops["features"]["tenon"] == 1 and ops["features"]["mortise"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_consumables_screws_and_glue():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
s.place("2x4", 24)
|
||||||
|
s.join("p2", "p1") # 1 butt joint
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
rates = E.EstimateRates(screws_per_butt_joint=2, screw_unit_cost=0.10)
|
||||||
|
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
|
||||||
|
screws = next(l for l in est.consumables if l.label == "Screws")
|
||||||
|
assert screws.cost == 0.20 # 2 screws × $0.10
|
||||||
|
|
||||||
|
|
||||||
|
def test_labor_scales_with_rate_and_ops():
|
||||||
|
s = Scene()
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
assert est.labor_cost == 10.00
|
||||||
|
|
||||||
|
|
||||||
|
def test_margin_pricing_formula():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
# zero everything except a known material cost, 0% HST, 50% margin
|
||||||
|
rates = E.EstimateRates(margin_pct=50.0, labor_rate_per_hr=0.0, setup_min=0,
|
||||||
|
screws_per_butt_joint=0, glue_oz_per_connection=0)
|
||||||
|
est = E.project_estimate(s, plan, prices={"2x4": 100.0}, rates=rates, hst=0.0)
|
||||||
|
assert est.total_cost == 100.0
|
||||||
|
assert est.price == 200.0 # 100 / (1 - 0.5)
|
||||||
|
assert est.profit == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_price_backsolves_margin():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
rates = E.EstimateRates(target_price=250.0, labor_rate_per_hr=0.0, setup_min=0,
|
||||||
|
screws_per_butt_joint=0)
|
||||||
|
est = E.project_estimate(s, plan, prices={"2x4": 100.0}, rates=rates, hst=0.0)
|
||||||
|
assert est.total_cost == 100.0
|
||||||
|
assert est.price == 250.0
|
||||||
|
# implied margin = (250-100)/250 = 60%
|
||||||
|
assert est.effective_margin == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_margin_guarded_against_div_zero():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
rates = E.EstimateRates(margin_pct=100.0, labor_rate_per_hr=0.0, setup_min=0,
|
||||||
|
screws_per_butt_joint=0)
|
||||||
|
est = E.project_estimate(s, plan, prices={"2x4": 50.0}, rates=rates, hst=0.0)
|
||||||
|
assert est.price > est.total_cost # clamped to 95%, no ZeroDivisionError
|
||||||
|
|
||||||
|
|
||||||
|
def test_rates_save_load_roundtrip(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||||
|
r = E.EstimateRates(margin_pct=42.0, labor_rate_per_hr=55.0)
|
||||||
|
r.min_per_feature["tenon"] = 99.0
|
||||||
|
E.save_rates(r)
|
||||||
|
loaded = E.load_rates()
|
||||||
|
assert loaded.margin_pct == 42.0 and loaded.labor_rate_per_hr == 55.0
|
||||||
|
assert loaded.min_per_feature["tenon"] == 99.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_includes_all_sections():
|
||||||
|
s = _table_scene()
|
||||||
|
s.finish("p1")
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
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
|
||||||
Loading…
Reference in New Issue