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:
parent
9d80be4e7f
commit
067ec0ea46
|
|
@ -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())
|
||||||
|
|
@ -8,9 +8,11 @@ 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, QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
|
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QGraphicsItem, QGraphicsRectItem,
|
||||||
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel,
|
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
|
||||||
QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
|
QHBoxLayout, QHeaderView, QLabel, QMenu, QPushButton,
|
||||||
|
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
|
||||||
|
QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
|
|
@ -20,6 +22,7 @@ from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement
|
||||||
snap_x, _plan_key)
|
snap_x, _plan_key)
|
||||||
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 .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
|
||||||
|
|
@ -74,11 +77,13 @@ class BomWindow(QDialog):
|
||||||
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
||||||
self.pool = QThreadPool.globalInstance()
|
self.pool = QThreadPool.globalInstance()
|
||||||
|
|
||||||
|
self._prices = prices_mod.load_prices()
|
||||||
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()
|
||||||
tabs.addTab(self._print_wrap(self._cut_te), "Cut List")
|
tabs.addTab(self._print_wrap(self._cut_te), "Cut List")
|
||||||
tabs.addTab(self._print_wrap(self._shop_te), "Shopping 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._layout_tab(), "Cut Layout")
|
||||||
tabs.addTab(self._instructions_tab(), "Instructions")
|
tabs.addTab(self._instructions_tab(), "Instructions")
|
||||||
tabs.addTab(self._jigs_tab(), "Jigs")
|
tabs.addTab(self._jigs_tab(), "Jigs")
|
||||||
|
|
@ -97,6 +102,7 @@ class BomWindow(QDialog):
|
||||||
self._shop_te.setPlainText(self._shop_text())
|
self._shop_te.setPlainText(self._shop_text())
|
||||||
self._instr.setPlainText(format_steps(build_steps(self.c.scene, self._plan)))
|
self._instr.setPlainText(format_steps(build_steps(self.c.scene, self._plan)))
|
||||||
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
|
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
|
||||||
|
self._cost_te.setPlainText(self._cost_text())
|
||||||
self._draw_layout()
|
self._draw_layout()
|
||||||
|
|
||||||
# ----- text tabs ----------------------------------------------------
|
# ----- text tabs ----------------------------------------------------
|
||||||
|
|
@ -161,6 +167,58 @@ class BomWindow(QDialog):
|
||||||
if QPrintDialog(printer, self).exec():
|
if QPrintDialog(printer, self).exec():
|
||||||
te.print_(printer)
|
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 --------------------------------------------
|
# ----- instructions tab --------------------------------------------
|
||||||
def _instructions_tab(self) -> QWidget:
|
def _instructions_tab(self) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
|
|
@ -449,3 +507,39 @@ class BomWindow(QDialog):
|
||||||
painter = QPainter(printer)
|
painter = QPainter(printer)
|
||||||
self.scene.render(painter)
|
self.scene.render(painter)
|
||||||
painter.end()
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -58,6 +58,29 @@ def test_best_of_n_button_keeps_valid_plan(tmp_path):
|
||||||
assert "best" in w._status.text().lower()
|
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):
|
def test_best_of_n_with_lock_runs_and_validates(tmp_path):
|
||||||
from woodshop.cutplan import validate_cut_plan
|
from woodshop.cutplan import validate_cut_plan
|
||||||
c = Controller(str(tmp_path / "s.json"))
|
c = Controller(str(tmp_path / "s.json"))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue