From 067ec0ea46c439356459b889326daf519d831d7b Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 16:23:59 -0300 Subject: [PATCH] Add cost estimate (Cost tab) with editable Kent NB price book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/fetch_kent_prices.py | 60 ++++++++ src/woodshop/gui/bom_window.py | 100 +++++++++++++- src/woodshop/prices.py | 242 +++++++++++++++++++++++++++++++++ tests/test_bom_window.py | 23 ++++ tests/test_prices.py | 77 +++++++++++ 5 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 scripts/fetch_kent_prices.py create mode 100644 src/woodshop/prices.py create mode 100644 tests/test_prices.py diff --git a/scripts/fetch_kent_prices.py b/scripts/fetch_kent_prices.py new file mode 100644 index 0000000..9d224f7 --- /dev/null +++ b/scripts/fetch_kent_prices.py @@ -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()) diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 55bd962..969ce71 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -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 diff --git a/src/woodshop/prices.py b/src/woodshop/prices.py new file mode 100644 index 0000000..5f1032a --- /dev/null +++ b/src/woodshop/prices.py @@ -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']*application/ld\+json[^>]*>(.*?)', + 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) diff --git a/tests/test_bom_window.py b/tests/test_bom_window.py index 3ec1292..e802fe5 100644 --- a/tests/test_bom_window.py +++ b/tests/test_bom_window.py @@ -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")) diff --git a/tests/test_prices.py b/tests/test_prices.py new file mode 100644 index 0000000..6ba8ffc --- /dev/null +++ b/tests/test_prices.py @@ -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 = ('') + assert P._parse_price(html) == 3.98 + + +def test_parse_price_none_when_absent(): + assert P._parse_price("no price here") is None