Mark purchased: scan a receipt to fill actual prices
The Mark-purchased dialog gains a "Scan receipt…" button: attach a receipt
photo/PDF and the model reads it, fills the unit price each item actually cost,
and offers to save those to the price book — so inventory spend and future
estimates reflect what you really paid.
- driver.read_receipt(path, labels): claude -p reads the receipt image/PDF and
returns {item label: unit price}; _extract_json_object parses the JSON object.
- PurchaseDialog(pool=): "Scan receipt…" resolves the file (image/PDF) and runs
read_receipt off the UI thread, filling matched price spins + a status line;
auto-ticks "save to price book" when prices were read.
- tests: JSON-object extraction, read_receipt parsing (drops non-numeric),
dialog price-fill path. 230 pass.
Honest limit: receipt OCR accuracy is the model's; review the filled prices
before confirming. Live read needs your machine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a4ef3a7d1e
commit
28ca8ee338
|
|
@ -326,6 +326,54 @@ def critique(reference_paths: list[str], render_paths: list[str], schemas: str,
|
||||||
return calls or [{"tool": "say", "args": {"text": "LGTM (no changes parsed)."}}]
|
return calls or [{"tool": "say", "args": {"text": "LGTM (no changes parsed)."}}]
|
||||||
|
|
||||||
|
|
||||||
|
_RECEIPT_PROMPT = (
|
||||||
|
"Read the store receipt saved at this path (open the image / PDF):\n {path}\n"
|
||||||
|
"It lists purchased building materials with prices. For EACH item below that I "
|
||||||
|
"bought, find its UNIT price (price per stick / sheet / each, BEFORE tax — not "
|
||||||
|
"the line total) on the receipt. Items I'm buying:\n{labels}\n"
|
||||||
|
"Respond with ONLY a JSON object mapping each item label to its unit price in "
|
||||||
|
'dollars, e.g. {{"2x4": 3.98, "oak 1x4": 14.50}}. Omit items you cannot find on '
|
||||||
|
"the receipt. No prose, no code fences.")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(raw: str) -> dict:
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw)
|
||||||
|
raw = re.sub(r"\n?```$", "", raw).strip()
|
||||||
|
start = raw.find("{")
|
||||||
|
if start == -1:
|
||||||
|
return {}
|
||||||
|
depth = 0
|
||||||
|
for i in range(start, len(raw)):
|
||||||
|
if raw[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif raw[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
try:
|
||||||
|
v = json.loads(raw[start:i + 1])
|
||||||
|
return v if isinstance(v, dict) else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def read_receipt(path: str, labels: list[str]) -> dict[str, float]:
|
||||||
|
"""Read a receipt image/PDF and return {item label: unit price} for the
|
||||||
|
purchased items it can find. Empty if nothing parses."""
|
||||||
|
prompt = _RECEIPT_PROMPT.format(path=os.path.abspath(path),
|
||||||
|
labels="\n".join(f" - {x}" for x in labels))
|
||||||
|
obj = _extract_json_object(_run(REASON_PROVIDER.split(), stdin=prompt))
|
||||||
|
out = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
try:
|
||||||
|
out[str(k)] = round(float(v), 2)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _subprocess_executor(tool: str, args: dict) -> str:
|
def _subprocess_executor(tool: str, args: dict) -> str:
|
||||||
"""Default executor: dispatch a wood-* tool via the CmdForge pa-execute-tool."""
|
"""Default executor: dispatch a wood-* tool via the CmdForge pa-execute-tool."""
|
||||||
result = _run(["pa-execute-tool", "--tool-name", tool,
|
result = _run(["pa-execute-tool", "--tool-name", tool,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,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 driver
|
||||||
from .. import prices as prices_mod
|
from .. import prices as prices_mod
|
||||||
from .. import estimate as estimate_mod
|
from .. import estimate as estimate_mod
|
||||||
from .. import inventory as inventory_mod
|
from .. import inventory as inventory_mod
|
||||||
|
|
@ -149,7 +150,7 @@ class BomWindow(QDialog):
|
||||||
if not bought:
|
if not bought:
|
||||||
QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.")
|
QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.")
|
||||||
return
|
return
|
||||||
dlg = PurchaseDialog(bought, self._prices, self)
|
dlg = PurchaseDialog(bought, self._prices, self, pool=self.pool)
|
||||||
if not dlg.exec():
|
if not dlg.exec():
|
||||||
return
|
return
|
||||||
rows, save = dlg.result()
|
rows, save = dlg.result()
|
||||||
|
|
@ -813,13 +814,15 @@ class RatesEditDialog(QDialog):
|
||||||
|
|
||||||
class PurchaseDialog(QDialog):
|
class PurchaseDialog(QDialog):
|
||||||
"""Confirm a shopping list before adding it to shop inventory; optionally
|
"""Confirm a shopping list before adding it to shop inventory; optionally
|
||||||
save the entered prices back to the price book (opt-in)."""
|
attach a receipt photo/PDF to fill the actual prices, and save them to the
|
||||||
|
price book (opt-in)."""
|
||||||
|
|
||||||
def __init__(self, bought, prices, parent=None):
|
def __init__(self, bought, prices, parent=None, pool=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
from ..lumber import default_material
|
from ..lumber import default_material
|
||||||
self.setWindowTitle("Mark purchased")
|
self.setWindowTitle("Mark purchased")
|
||||||
self.resize(380, 320)
|
self.resize(400, 360)
|
||||||
|
self.pool = pool
|
||||||
mults = prices_mod.load_material_multipliers()
|
mults = prices_mod.load_material_multipliers()
|
||||||
v = QVBoxLayout(self)
|
v = QVBoxLayout(self)
|
||||||
v.addWidget(QLabel("Add these to your shop inventory?"))
|
v.addWidget(QLabel("Add these to your shop inventory?"))
|
||||||
|
|
@ -828,6 +831,7 @@ class PurchaseDialog(QDialog):
|
||||||
self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"])
|
self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"])
|
||||||
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
self._spins = []
|
self._spins = []
|
||||||
|
self._by_label = {} # label -> price spin (for receipt fill)
|
||||||
for r, ((stock, material), qty) in enumerate(rows):
|
for r, ((stock, material), qty) in enumerate(rows):
|
||||||
label = (f"{material} {stock}" if material and material != default_material(stock)
|
label = (f"{material} {stock}" if material and material != default_material(stock)
|
||||||
else stock)
|
else stock)
|
||||||
|
|
@ -840,13 +844,59 @@ class PurchaseDialog(QDialog):
|
||||||
self._table.setCellWidget(r, 1, q)
|
self._table.setCellWidget(r, 1, q)
|
||||||
self._table.setCellWidget(r, 2, p)
|
self._table.setCellWidget(r, 2, p)
|
||||||
self._spins.append((stock, material, q, p))
|
self._spins.append((stock, material, q, p))
|
||||||
|
self._by_label[label] = p
|
||||||
v.addWidget(self._table)
|
v.addWidget(self._table)
|
||||||
|
|
||||||
|
if pool is not None: # receipt scanning needs a worker pool
|
||||||
|
rrow = QHBoxLayout()
|
||||||
|
self._receipt_btn = QPushButton("Scan receipt…")
|
||||||
|
self._receipt_btn.setToolTip("Read a receipt photo/PDF and fill the actual "
|
||||||
|
"prices you paid")
|
||||||
|
self._receipt_btn.clicked.connect(self._scan_receipt)
|
||||||
|
rrow.addWidget(self._receipt_btn)
|
||||||
|
self._receipt_status = QLabel("")
|
||||||
|
self._receipt_status.setStyleSheet("color:#c8965a")
|
||||||
|
rrow.addWidget(self._receipt_status, 1)
|
||||||
|
v.addLayout(rrow)
|
||||||
|
|
||||||
self._save = QCheckBox("Save these prices to my price book")
|
self._save = QCheckBox("Save these prices to my price book")
|
||||||
v.addWidget(self._save)
|
v.addWidget(self._save)
|
||||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
|
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
|
||||||
v.addWidget(bb)
|
v.addWidget(bb)
|
||||||
|
|
||||||
|
def _scan_receipt(self) -> None:
|
||||||
|
exts = " ".join("*" + e for e in sorted(driver.IMG_EXTS | driver.DOC_EXTS))
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self, "Scan receipt", "",
|
||||||
|
f"Receipt image/PDF ({exts});;All files (*)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
labels = list(self._by_label)
|
||||||
|
self._receipt_btn.setEnabled(False)
|
||||||
|
self._receipt_status.setText("reading receipt…")
|
||||||
|
|
||||||
|
def work():
|
||||||
|
img, _txt = driver.resolve_reference(path) # image/PDF -> a path
|
||||||
|
return driver.read_receipt(img or path, labels)
|
||||||
|
|
||||||
|
def done(prices_map):
|
||||||
|
self._receipt_btn.setEnabled(True)
|
||||||
|
n = 0
|
||||||
|
for label, price in (prices_map or {}).items():
|
||||||
|
spin = self._by_label.get(label)
|
||||||
|
if spin is not None and price > 0:
|
||||||
|
spin.setValue(price)
|
||||||
|
n += 1
|
||||||
|
self._save.setChecked(n > 0) # offer to keep the real prices
|
||||||
|
self._receipt_status.setText(
|
||||||
|
f"✓ filled {n} price(s) from receipt" if n else "couldn't read prices — enter by hand")
|
||||||
|
|
||||||
|
def failed(err):
|
||||||
|
self._receipt_btn.setEnabled(True)
|
||||||
|
self._receipt_status.setText(f"⚠ {err}")
|
||||||
|
|
||||||
|
run_async(self.pool, work, on_done=done, on_error=failed)
|
||||||
|
|
||||||
def result(self):
|
def result(self):
|
||||||
rows = [(stock, material, q.value(), round(p.value(), 2))
|
rows = [(stock, material, q.value(), round(p.value(), 2))
|
||||||
for stock, material, q, p in self._spins if q.value() > 0]
|
for stock, material, q, p in self._spins if q.value() > 0]
|
||||||
|
|
|
||||||
|
|
@ -175,3 +175,24 @@ def test_optimize_preserves_offcut_toggle(tmp_path, monkeypatch):
|
||||||
assert w._plan.score["stock_count"] == 0
|
assert w._plan.score["stock_count"] == 0
|
||||||
w._best_of_n()
|
w._best_of_n()
|
||||||
assert w._plan.score["stock_count"] == 0
|
assert w._plan.score["stock_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_purchase_dialog_receipt_fills_prices(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
|
||||||
|
from collections import Counter
|
||||||
|
from woodshop.gui.bom_window import PurchaseDialog
|
||||||
|
from PySide6.QtCore import QThreadPool
|
||||||
|
bought = Counter({("2x4", "spruce"): 3, ("ply-3/4", "spruce-ply"): 1})
|
||||||
|
dlg = PurchaseDialog(bought, {"2x4": 4.0, "ply-3/4": 60.0}, pool=QThreadPool.globalInstance())
|
||||||
|
# simulate the worker's done() applying receipt-read prices
|
||||||
|
dlg._by_label["2x4"].setValue(0.0)
|
||||||
|
prices_map = {"2x4": 3.49, "ply-3/4": 58.0, "unknown": 9.0}
|
||||||
|
n = 0
|
||||||
|
for label, price in prices_map.items():
|
||||||
|
spin = dlg._by_label.get(label)
|
||||||
|
if spin is not None and price > 0:
|
||||||
|
spin.setValue(price); n += 1
|
||||||
|
assert n == 2
|
||||||
|
rows, _ = dlg.result()
|
||||||
|
by_stock = {s: pr for s, _m, _q, pr in rows}
|
||||||
|
assert by_stock["2x4"] == 3.49 and by_stock["ply-3/4"] == 58.0
|
||||||
|
|
|
||||||
|
|
@ -272,3 +272,18 @@ def test_render_mesh_real_if_possible(tmp_path):
|
||||||
assert _os.path.exists(png) and png.endswith(".png")
|
assert _os.path.exists(png) and png.endswith(".png")
|
||||||
assert "bounding box" in dims
|
assert "bounding box" in dims
|
||||||
_os.remove(png)
|
_os.remove(png)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_json_object():
|
||||||
|
assert driver._extract_json_object('```json\n{"2x4": 3.98}\n```') == {"2x4": 3.98}
|
||||||
|
assert driver._extract_json_object('here: {"a": 1} done') == {"a": 1}
|
||||||
|
assert driver._extract_json_object("no object") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_receipt_parses_unit_prices(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="":
|
||||||
|
captured.update(prompt=stdin) or '{"2x4": 3.98, "oak 1x4": 14.5, "bad": "x"}')
|
||||||
|
out = driver.read_receipt("/tmp/receipt.jpg", ["2x4", "oak 1x4", "ply-3/4"])
|
||||||
|
assert out == {"2x4": 3.98, "oak 1x4": 14.5} # 'bad' non-numeric dropped
|
||||||
|
assert "UNIT price" in captured["prompt"] and "/tmp/receipt.jpg" in captured["prompt"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue