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)."}}]
|
||||
|
||||
|
||||
_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:
|
||||
"""Default executor: dispatch a wood-* tool via the CmdForge pa-execute-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)
|
||||
from ..instructions import build_steps, format_steps, polish_prompt
|
||||
from ..jigs import explain_prompt, format_jigs, suggest_jigs
|
||||
from .. import driver
|
||||
from .. import prices as prices_mod
|
||||
from .. import estimate as estimate_mod
|
||||
from .. import inventory as inventory_mod
|
||||
|
|
@ -149,7 +150,7 @@ class BomWindow(QDialog):
|
|||
if not bought:
|
||||
QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.")
|
||||
return
|
||||
dlg = PurchaseDialog(bought, self._prices, self)
|
||||
dlg = PurchaseDialog(bought, self._prices, self, pool=self.pool)
|
||||
if not dlg.exec():
|
||||
return
|
||||
rows, save = dlg.result()
|
||||
|
|
@ -813,13 +814,15 @@ class RatesEditDialog(QDialog):
|
|||
|
||||
class PurchaseDialog(QDialog):
|
||||
"""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)
|
||||
from ..lumber import default_material
|
||||
self.setWindowTitle("Mark purchased")
|
||||
self.resize(380, 320)
|
||||
self.resize(400, 360)
|
||||
self.pool = pool
|
||||
mults = prices_mod.load_material_multipliers()
|
||||
v = QVBoxLayout(self)
|
||||
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.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self._spins = []
|
||||
self._by_label = {} # label -> price spin (for receipt fill)
|
||||
for r, ((stock, material), qty) in enumerate(rows):
|
||||
label = (f"{material} {stock}" if material and material != default_material(stock)
|
||||
else stock)
|
||||
|
|
@ -840,13 +844,59 @@ class PurchaseDialog(QDialog):
|
|||
self._table.setCellWidget(r, 1, q)
|
||||
self._table.setCellWidget(r, 2, p)
|
||||
self._spins.append((stock, material, q, p))
|
||||
self._by_label[label] = p
|
||||
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")
|
||||
v.addWidget(self._save)
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
|
||||
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):
|
||||
rows = [(stock, material, q.value(), round(p.value(), 2))
|
||||
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
|
||||
w._best_of_n()
|
||||
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 "bounding box" in dims
|
||||
_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