Wires the ledger into the BOM window via three workflows (Codex's
workflow-first UX), plus an offcut-consuming planner.
- StockPiece gains owned/source; build_cut_plan(available=) seeds the packer
from owned offcuts (reusing the seeded-packing) so they're consumed before
buying. Score reports stock_count = pieces to BUY (offcuts free) + owned_count;
prices.estimate and the Buy list exclude owned pieces.
- BOM header: "Build units", an opt-in "Use shop offcuts" toggle, and
"Mark purchased…" / "Record build…" buttons.
- PurchaseDialog: confirm qty + price each, opt-in "save prices to price book".
- RecordBuildDialog: shows consumed stock + each offcut with Keep/Burn/Trash/
Ignore before committing (the moment to correct reality).
- Ledger.record_build takes per-offcut dispositions; used offcuts are consumed
from the bin; build cost snapshot logged.
- tests: offcut toggle drops buy-count to 0, record-build writes the ledger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Codex findings:
1. reoptimize sent unlocked plywood to fresh sheets whenever any sheet
placement was locked, instead of packing into free space on the locked
sheet — so locking one of two panels that share a sheet split them onto
two sheets. Added _free_rects_sheet (guillotine subtraction carving free
rectangles around locked panels) + _pack_plywood_seeded, and refactored
_pack_plywood_guillotine onto a shared _guillotine_pack core that accepts
seeded sheets. reoptimize now uses it for the plywood branch.
2. "Best of 100" only tried the ~6 STRATEGIES when locks existed. The locked
path now runs strategies + shuffle restarts up to 100 attempts via
reoptimize, matching the label.
Tests: plywood lock keeps both panels on one sheet; locked Best-of-100 stays valid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_drop_piece looked up plan.item(item.pid), but item.pid is a Placement id (pl2)
while CutPlan.item() expects a CutItem id (ci2) — every drop raised StopIteration
before validate/revert could run. Use the already-found placement's item_id
(plan.item(p.item_id)) for the stock-compat check and message.
Added tests/test_bom_window.py (offscreen QGraphicsScene): drop-overlap reverts
without crashing, drop-onto-incompatible-stock reverts, and a valid move commits.
128 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>