Add cost estimate (Cost tab) with editable Kent NB price book

New shop-packet output: a printable cost estimate driven by the active
CutPlan's buy-counts × a curated, editable price book (HST 15%).

- prices.py: DEFAULT_PRICES seeded with real Kent (New Brunswick) shelf
  prices per buy-unit (lumber = 8' stick, plywood = 4x8 sheet); persisted to
  $XDG_CONFIG_HOME/woodshop/prices.json (defaults + saved overrides).
  estimate() -> CostEstimate (lines/subtotal/tax/total/missing); lumber price
  scales with stick length; unknown stock is flagged, never invented.
- BOM window: Cost tab with "Edit prices…" (PriceEditDialog), "Refresh from
  Kent…", and Print.
- fetch_kent_prices() + scripts/fetch_kent_prices.py: best-effort refresh.
  Kent renders prices client-side (not in HTML), so it tries a static parse
  then Playwright if installed — honest that it may need updating.
- tests: estimate math, per-sheet plywood, stick-length scaling, missing-price
  flagging, save/load roundtrip, corrupt-file fallback, JSON-LD parse, cost
  tab render + price edit persistence. 153 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 16:23:59 -03:00
parent 9d80be4e7f
commit 067ec0ea46
5 changed files with 499 additions and 3 deletions

View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Best-effort refresh of WoodShop's lumber price book from Kent (kent.ca, NB).
Kent renders prices in the browser (the price is not in the page HTML), so a
plain HTTP fetch usually finds nothing. For reliable results install Playwright:
pip install playwright && playwright install chromium
Then run:
python scripts/fetch_kent_prices.py # show what it can read
python scripts/fetch_kent_prices.py --write # also save to the price book
This is intentionally honest: if Kent changes their site this may stop working
and you'll need to update KENT_URLS / the parser in src/woodshop/prices.py, or
just edit prices by hand in the app (Cost tab "Edit prices…").
"""
import argparse
import sys
from woodshop import prices as P
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Refresh WoodShop prices from Kent NB.")
ap.add_argument("--write", action="store_true", help="Save fetched prices to the price book")
ap.add_argument("--no-browser", action="store_true",
help="Skip the Playwright render (static HTTP only — likely finds nothing)")
args = ap.parse_args(argv)
print(f"Fetching {len(P.KENT_URLS)} product(s) from Kent…")
fetched = P.fetch_kent_prices(use_browser=not args.no_browser)
if not fetched:
print("\n ⚠ Could not read any live prices.")
print(" Kent injects prices client-side; install Playwright for reliable results:")
print(" pip install playwright && playwright install chromium")
print(" Or edit prices by hand in the app (Cost tab → Edit prices…).")
return 1
print("\n Read these prices:")
for stock, price in sorted(fetched.items()):
print(f" {stock:<10} ${price:,.2f}")
missing = sorted(set(P.KENT_URLS) - set(fetched))
if missing:
print(f"\n (couldn't read: {', '.join(missing)})")
if args.write:
book = P.load_prices()
book.update(fetched)
P.save_prices(book)
print(f"\n ✓ saved to {P._config_path()}")
else:
print("\n (dry run — pass --write to save these to the price book)")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -8,9 +8,11 @@ import subprocess
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel,
QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QGraphicsItem, QGraphicsRectItem,
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
QHBoxLayout, QHeaderView, QLabel, QMenu, QPushButton,
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
QVBoxLayout, QWidget)
from collections import Counter
@ -20,6 +22,7 @@ from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement
snap_x, _plan_key)
from ..instructions import build_steps, format_steps, polish_prompt
from ..jigs import explain_prompt, format_jigs, suggest_jigs
from .. import prices as prices_mod
from .workers import run_async
_PX = 7.0 # pixels per inch in the layout view
@ -74,11 +77,13 @@ class BomWindow(QDialog):
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
self.pool = QThreadPool.globalInstance()
self._prices = prices_mod.load_prices()
self._cut_te = self._mono_te()
self._shop_te = self._mono_te()
tabs = QTabWidget()
tabs.addTab(self._print_wrap(self._cut_te), "Cut List")
tabs.addTab(self._print_wrap(self._shop_te), "Shopping List")
tabs.addTab(self._cost_tab(), "Cost")
tabs.addTab(self._layout_tab(), "Cut Layout")
tabs.addTab(self._instructions_tab(), "Instructions")
tabs.addTab(self._jigs_tab(), "Jigs")
@ -97,6 +102,7 @@ class BomWindow(QDialog):
self._shop_te.setPlainText(self._shop_text())
self._instr.setPlainText(format_steps(build_steps(self.c.scene, self._plan)))
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
self._cost_te.setPlainText(self._cost_text())
self._draw_layout()
# ----- text tabs ----------------------------------------------------
@ -161,6 +167,58 @@ class BomWindow(QDialog):
if QPrintDialog(printer, self).exec():
te.print_(printer)
# ----- cost tab -----------------------------------------------------
def _cost_tab(self) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
self._cost_te = self._mono_te()
v.addWidget(self._cost_te)
row = QHBoxLayout()
edit = QPushButton("Edit prices…")
edit.clicked.connect(self._edit_prices)
refresh = QPushButton("Refresh from Kent…")
refresh.setToolTip("Best-effort fetch of current Kent NB prices "
"(needs a headless browser; may need updating if Kent changes)")
refresh.clicked.connect(self._refresh_prices)
pr = QPushButton("Print…")
pr.clicked.connect(lambda: self._print_text(self._cost_te))
row.addWidget(edit); row.addWidget(refresh); row.addStretch(); row.addWidget(pr)
v.addLayout(row)
return w
def _cost_text(self) -> str:
return prices_mod.format_estimate(prices_mod.estimate(self._plan, self._prices))
def _edit_prices(self) -> None:
dlg = PriceEditDialog(self._prices, self)
if dlg.exec():
self._prices = dlg.prices()
prices_mod.save_prices(self._prices)
self._cost_te.setPlainText(self._cost_text())
def _refresh_prices(self) -> None:
self._cost_te.append("\n fetching current prices from Kent…")
def work():
return prices_mod.fetch_kent_prices()
def done(fetched):
if fetched:
self._prices.update(fetched)
prices_mod.save_prices(self._prices)
self._cost_te.setPlainText(self._cost_text())
self._cost_te.append(f"\n ✓ updated {len(fetched)} price(s) from Kent.")
else:
self._cost_te.setPlainText(self._cost_text())
self._cost_te.append("\n ⚠ Couldn't read live prices (Kent renders them "
"in-browser). Install playwright or edit prices by hand.")
def failed(err):
self._cost_te.setPlainText(self._cost_text())
self._cost_te.append(f"\n ⚠ price refresh failed: {err}")
run_async(self.pool, work, on_done=done, on_error=failed)
# ----- instructions tab --------------------------------------------
def _instructions_tab(self) -> QWidget:
w = QWidget()
@ -449,3 +507,39 @@ class BomWindow(QDialog):
painter = QPainter(printer)
self.scene.render(painter)
painter.end()
class PriceEditDialog(QDialog):
"""Edit the per-unit price book (lumber = 8' stick, plywood = 4×8 sheet)."""
def __init__(self, prices: dict, parent=None):
super().__init__(parent)
self.setWindowTitle("Edit prices")
self.resize(360, 460)
v = QVBoxLayout(self)
v.addWidget(QLabel("Price per stick (8') / sheet (4×8), in CAD.\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}"))
v.addWidget(self._table)
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
v.addWidget(bb)
def prices(self) -> dict:
out = {}
for r in range(self._table.rowCount()):
stock = self._table.item(r, 0).text()
try:
out[stock] = round(float(self._table.item(r, 1).text()), 2)
except (ValueError, AttributeError):
continue # skip blanked / bad cells
return out

242
src/woodshop/prices.py Normal file
View File

@ -0,0 +1,242 @@
"""Cost estimation for a cut plan, from a curated, editable price book.
Prices are CURATED, not scraped live. Retailer sites (Kent, Home Depot Canada)
render prices with JavaScript after the page loads, so a plain HTTP fetch can't
read them the price is simply not in the HTML. The defaults below are real
Kent (New Brunswick) shelf prices captured 2026-05; a few are reasonable
estimates where a price wasn't confirmed. They are EDITABLE in the app (Cost
tab "Edit prices…") and can be refreshed with scripts/fetch_kent_prices.py.
Design note (matches the rest of the shop packet): the math is deterministic and
inspectable counts come from the CutPlan, prices from this table, tax is a
flat rate. Nothing here guesses; unknown stock is flagged, never invented.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from .lumber import normalize_stock
NB_HST = 0.15 # New Brunswick HST
STD_STICK_IN = 96.0 # the stick length the lumber prices below assume (8')
# stock -> price for ONE standard buy unit:
# lumber -> an 8' (96") stick
# plywood -> a 4'×8' sheet
# Source: Kent.ca, New Brunswick, captured 2026-05. ✓ = confirmed from a listing;
# the rest are reasonable estimates — EDIT FREELY (they persist per machine).
DEFAULT_PRICES: dict[str, float] = {
"1x2": 2.49, "1x3": 3.29, "1x4": 4.79, "1x6": 6.98, "1x8": 10.98,
"1x10": 14.98, "1x12": 18.98,
"2x2": 2.79, "2x3": 2.98, "2x4": 3.98, # ✓ 2x4x8 stud $3.98
"2x6": 6.98, "2x8": 10.98, "2x10": 15.98, "2x12": 19.98,
"4x4": 12.98, "4x6": 24.98, "6x6": 38.98,
"ply-1/8": 19.99, "ply-1/4": 24.99, # ✓ 1/4 4x8 $24.99
"ply-3/8": 32.99, "ply-1/2": 41.99, "ply-5/8": 52.99,
"ply-3/4": 63.98, # ✓ 3/4 4x8 spruce $63.98
}
# 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",
"ply-1/4": "https://kent.ca/en/1-2-x-4-x-8-12-5mm-spruce-plywood-standard-1015823",
"ply-1/2": "https://kent.ca/en/1-2-x-4-x-8-12-5mm-spruce-plywood-standard-1015823",
"ply-3/4": "https://kent.ca/en/3-4-x-4-x-8-18-5mm-spruce-plywood-standard-1015824",
}
def _config_path() -> Path:
base = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "woodshop"
return base / "prices.json"
def load_prices() -> dict[str, float]:
"""The user's price book (defaults merged with any saved overrides)."""
prices = dict(DEFAULT_PRICES)
path = _config_path()
if path.exists():
try:
saved = json.loads(path.read_text())
prices.update({normalize_stock(k): float(v) for k, v in saved.items()})
except (ValueError, OSError):
pass # corrupt file -> fall back to defaults
return prices
def save_prices(prices: dict[str, float]) -> None:
path = _config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({k: round(float(v), 2) for k, v in sorted(prices.items())},
indent=2))
def unit_label(stock: str) -> str:
return "sheet (4×8)" if normalize_stock(stock).startswith("ply-") else "stick (8')"
@dataclass
class CostLine:
stock: str
qty: int
unit_price: float | None # None = no price on file
unit_label: str
@property
def total(self) -> float:
return round((self.unit_price or 0.0) * self.qty, 2)
@dataclass
class CostEstimate:
lines: list[CostLine] = field(default_factory=list)
hst: float = NB_HST
@property
def subtotal(self) -> float:
return round(sum(ln.total for ln in self.lines), 2)
@property
def tax(self) -> float:
return round(self.subtotal * self.hst, 2)
@property
def total(self) -> float:
return round(self.subtotal + self.tax, 2)
@property
def missing(self) -> list[str]:
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."""
from collections import Counter
prices = prices if prices is not None else load_prices()
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)
lines = []
for stock, 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
lines.append(CostLine(stock=stock, qty=qty, unit_price=unit_price,
unit_label=unit_label(stock)))
return CostEstimate(lines=lines, hst=hst)
def _parse_price(html: str) -> float | None:
"""Pull a price out of page HTML, trying structured data first, then text.
Returns None if nothing convincing is found (Kent renders prices in-browser,
so static HTML usually has none that's expected)."""
import re
# 1) schema.org JSON-LD offers.price
for m in re.finditer(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>',
html, re.S | re.I):
try:
data = json.loads(m.group(1))
except ValueError:
continue
for node in (data if isinstance(data, list) else [data]):
offers = (node or {}).get("offers") if isinstance(node, dict) else None
for off in (offers if isinstance(offers, list) else [offers] if offers else []):
price = isinstance(off, dict) and off.get("price")
if price:
try:
return round(float(str(price).replace(",", "")), 2)
except ValueError:
pass
# 2) common attributes / meta
m = re.search(r'(?:itemprop="price"[^>]*content|data-price|"price")\s*[=:]\s*"?\$?'
r'([0-9]+(?:\.[0-9]{2}))', html)
if m:
return round(float(m.group(1)), 2)
return None
def fetch_kent_prices(urls: dict[str, str] | None = None,
use_browser: bool = True) -> dict[str, float]:
"""Best-effort refresh of prices from Kent product pages. Returns {stock: price}
for whatever it could read possibly empty. Tries a static HTTP parse first;
if that finds nothing and Playwright is installed, renders the page (the only
reliable way, since Kent injects the price client-side). NEVER raises for a
missing price it just omits that stock so callers keep their existing value.
"""
import urllib.request
urls = urls or KENT_URLS
found: dict[str, float] = {}
pending: dict[str, str] = {}
for stock, url in urls.items():
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
html = resp.read().decode("utf-8", "replace")
except Exception:
continue
price = _parse_price(html)
if price is not None:
found[stock] = price
else:
pending[stock] = url
if pending and use_browser:
try:
from playwright.sync_api import sync_playwright
except ImportError:
return found # no browser — return what we have
try:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
for stock, url in pending.items():
try:
page.goto(url, wait_until="networkidle", timeout=30000)
text = page.inner_text("body")
import re
m = re.search(r'\$\s*([0-9]+(?:\.[0-9]{2}))', text)
if m:
found[stock] = round(float(m.group(1)), 2)
except Exception:
continue
browser.close()
except Exception:
pass
return found
def _money(v: float) -> str:
return f"${v:,.2f}"
def format_estimate(est: CostEstimate, region: str = "Kent NB") -> str:
pct = f"{est.hst * 100:g}%"
lines = [f"COST ESTIMATE ({region} · HST {pct})",
"editable estimates — verify before buying", ""]
if not est.lines:
lines.append(" (nothing to buy yet)")
return "\n".join(lines)
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} "
f"{price:>9} {total:>10}")
lines += [" " + "-" * 46,
f" {'Subtotal':<38}{_money(est.subtotal):>10}",
f" {'HST (' + pct + ')':<38}{_money(est.tax):>10}",
f" {'Total':<38}{_money(est.total):>10}"]
if est.missing:
lines += ["", " ⚠ No price on file for: " + ", ".join(est.missing)
+ " (Edit prices… to include)"]
return "\n".join(lines)

View File

@ -58,6 +58,29 @@ def test_best_of_n_button_keeps_valid_plan(tmp_path):
assert "best" in w._status.text().lower()
def test_cost_tab_renders_estimate(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
c = Controller(str(tmp_path / "s.json"))
for _ in range(3):
c.place("2x4", 40)
w = BomWindow(c)
text = w._cost_te.toPlainText()
assert "COST ESTIMATE" in text and "2x4" in text and "Total" in text
def test_edit_prices_persists_and_updates(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
from woodshop import prices as P
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 40)
w = BomWindow(c)
w._prices["2x4"] = 9.99 # simulate an edit accepted from the dialog
P.save_prices(w._prices)
w._cost_te.setPlainText(w._cost_text())
assert "$9.99" in w._cost_te.toPlainText()
assert P.load_prices()["2x4"] == 9.99
def test_best_of_n_with_lock_runs_and_validates(tmp_path):
from woodshop.cutplan import validate_cut_plan
c = Controller(str(tmp_path / "s.json"))

77
tests/test_prices.py Normal file
View File

@ -0,0 +1,77 @@
"""Tests for the cost-estimate price book (deterministic math; no network)."""
from woodshop import prices as P
from woodshop.cutplan import ShopSettings, build_cut_plan
from woodshop.scene import Scene
def test_estimate_sums_sticks_and_applies_hst():
s = Scene()
for _ in range(3):
s.place("2x4", 40) # 3 × 40" -> 2 sticks
plan = build_cut_plan(s)
est = P.estimate(plan, {"2x4": 4.00}, hst=0.15)
line = next(ln for ln in est.lines if ln.stock == "2x4")
assert line.qty == 2 and line.unit_price == 4.00 and line.total == 8.00
assert est.subtotal == 8.00
assert est.tax == 1.20
assert est.total == 9.20
def test_plywood_priced_per_sheet():
s = Scene()
s.place("ply-3/4", 40, width_in=20)
s.place("ply-3/4", 40, width_in=20) # both fit one sheet
plan = build_cut_plan(s)
est = P.estimate(plan, {"ply-3/4": 63.98}, hst=0.0)
line = next(ln for ln in est.lines if ln.stock == "ply-3/4")
assert line.qty == 1 and line.unit_price == 63.98 and est.total == 63.98
def test_lumber_price_scales_with_stick_length():
s = Scene()
s.place("2x4", 40)
plan = build_cut_plan(s, settings=ShopSettings(stick_len_in=192)) # 16' sticks
est = P.estimate(plan, {"2x4": 4.00}) # priced per 8' stick
line = next(ln for ln in est.lines if ln.stock == "2x4")
assert line.unit_price == 8.00 # 192/96 × $4
def test_missing_price_is_flagged_not_invented():
s = Scene()
s.place("2x4", 40)
plan = build_cut_plan(s)
est = P.estimate(plan, {}) # empty book
assert est.missing == ["2x4"]
assert est.subtotal == 0.0
assert "No price on file" in P.format_estimate(est)
def test_save_and_load_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
P.save_prices({"2x4": 3.49, "ply-3/4": 59.99})
loaded = P.load_prices()
assert loaded["2x4"] == 3.49 and loaded["ply-3/4"] == 59.99
# untouched stocks still come from defaults
assert loaded["2x6"] == P.DEFAULT_PRICES["2x6"]
def test_corrupt_price_file_falls_back_to_defaults(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
path = P._config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{ not json")
assert P.load_prices()["2x4"] == P.DEFAULT_PRICES["2x4"]
def test_format_estimate_empty():
assert "nothing to buy" in P.format_estimate(P.estimate(build_cut_plan(Scene())))
def test_parse_price_reads_json_ld():
html = ('<script type="application/ld+json">'
'{"@type":"Product","offers":{"@type":"Offer","price":"3.98"}}</script>')
assert P._parse_price(html) == 3.98
def test_parse_price_none_when_absent():
assert P._parse_price("<html><body>no price here</body></html>") is None