diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index 88694c5..2b8517e 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -14,7 +14,7 @@ import hashlib from dataclasses import asdict, dataclass, field, fields from .cutlist import cut_length -from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood +from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood, normalize_stock _EPS = 1e-6 @@ -103,6 +103,8 @@ class StockPiece: width_in: float placements: list = field(default_factory=list) # Placement waste: list = field(default_factory=list) # WasteRegion + owned: bool = False # True = an offcut you already have (not bought) + source: str = "" # offcut id / origin, when owned @dataclass @@ -137,7 +139,8 @@ class CutPlan: id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"], length_in=s["length_in"], width_in=s["width_in"], placements=[Placement(**p) for p in s.get("placements", [])], - waste=[WasteRegion(**w) for w in s.get("waste", [])]) + waste=[WasteRegion(**w) for w in s.get("waste", [])], + owned=s.get("owned", False), source=s.get("source", "")) return cls( settings=ShopSettings.from_dict(d.get("settings")), items=[CutItem(**i) for i in d.get("items", [])], @@ -431,8 +434,21 @@ def _pack_plywood_seeded(items, stock, s, ids, seeds) -> tuple[list, list]: return _guillotine_pack(items, stock, s, ids, seed_sheets) +def _offcut_seeds(available, stock, ids) -> list: + """StockPieces representing owned offcuts of `stock`, to fill before buying.""" + seeds = [] + for oc in available or []: + if normalize_stock(getattr(oc, "stock", "")) != stock: + continue + seeds.append(StockPiece(id=ids("oc"), stock=stock, is_sheet=oc.is_sheet, + length_in=oc.length_in, width_in=oc.width_in, + owned=True, source=getattr(oc, "id", ""))) + return seeds + + def build_cut_plan(scene, settings: ShopSettings | None = None, - strategy: str = "decreasing", quantity: int = 1) -> CutPlan: + strategy: str = "decreasing", quantity: int = 1, + available=None) -> CutPlan: from dataclasses import replace s = settings or ShopSettings() @@ -455,9 +471,15 @@ def build_cut_plan(scene, settings: ShopSettings | None = None, stock_pieces, unplaced, warnings = [], [], [] for stock, its in by_stock.items(): + seeds = _offcut_seeds(available, stock, ids) if its[0].is_sheet: - sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine" - else _pack_plywood(its, stock, s, ids)) + if seeds: + sps, un = _pack_plywood_seeded(its, stock, s, ids, seeds) + else: + sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine" + else _pack_plywood(its, stock, s, ids)) + elif seeds: + sps, un = _pack_lumber_seeded(its, stock, s, ids, seeds) elif strategy == "exact": sps, un = _pack_lumber_exact(its, stock, s, ids) else: @@ -478,14 +500,17 @@ def build_cut_plan(scene, settings: ShopSettings | None = None, def _score(stock_pieces, s, strategy, warnings) -> dict: waste_area = used_area = bought_area = 0.0 reusable = 0 + bought = [sp for sp in stock_pieces if not sp.owned] for sp in stock_pieces: used = sum(p.len_in * p.wid_in for p in sp.placements) used_area += used if sp.is_sheet: - bought_area += sp.length_in * sp.width_in + if not sp.owned: + bought_area += sp.length_in * sp.width_in waste_area += sp.length_in * sp.width_in - used else: - bought_area += sp.length_in * sp.width_in + if not sp.owned: + bought_area += sp.length_in * sp.width_in for w in sp.waste: waste_area += w.length_in * (w.width_in or sp.width_in) if w.reusable: @@ -494,7 +519,8 @@ def _score(stock_pieces, s, strategy, warnings) -> dict: for w in sp.waste if w.reusable) return { "strategy_name": strategy, - "stock_count": len(stock_pieces), + "stock_count": len(bought), # pieces you must BUY (offcuts free) + "owned_count": len(stock_pieces) - len(bought), "waste_area": round(waste_area, 1), "reusable_offcuts": reusable, "reusable_in": round(reusable_in, 1), @@ -617,14 +643,15 @@ STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shu def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24, - quantity: int = 1) -> CutPlan: + quantity: int = 1, available=None) -> CutPlan: """Find a better layout by trying several strategies + shuffle restarts and keeping the best-scoring one. (Good and explainable, not provably optimal.)""" strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"] strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))] best = None for st in strategies: - plan = build_cut_plan(scene, settings, strategy=st, quantity=quantity) + plan = build_cut_plan(scene, settings, strategy=st, quantity=quantity, + available=available) if best is None or _plan_key(plan) < _plan_key(best): best = plan if best is not None: diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 485cafa..2082132 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -8,12 +8,12 @@ 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, QDialogButtonBox, QDoubleSpinBox, QFormLayout, - QGraphicsItem, QGraphicsRectItem, QGraphicsScene, - QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, - QHeaderView, QLabel, QMenu, QPushButton, QScrollArea, - QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget, - QTextEdit, QVBoxLayout, QWidget) +from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFormLayout, QGraphicsItem, QGraphicsRectItem, + QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, + QHBoxLayout, QHeaderView, QLabel, QMenu, QMessageBox, + QPushButton, QScrollArea, QSpinBox, QTableWidget, + QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, QWidget) from collections import Counter @@ -25,6 +25,8 @@ 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 .. import estimate as estimate_mod +from .. import inventory as inventory_mod +from ..inventory import plan_consumption from .workers import run_async _PX = 7.0 # pixels per inch in the layout view @@ -82,6 +84,8 @@ class BomWindow(QDialog): self._prices = prices_mod.load_prices() self._rates = estimate_mod.load_rates() + self._ledger = inventory_mod.Ledger.load() + self._use_offcuts = False self._cut_te = self._mono_te() self._shop_te = self._mono_te() tabs = QTabWidget() @@ -92,7 +96,8 @@ class BomWindow(QDialog): tabs.addTab(self._instructions_tab(), "Instructions") tabs.addTab(self._jigs_tab(), "Jigs") - # header: build quantity (nests all units together → real per-unit cost) + # header: build quantity (nests all units together → real per-unit cost), + # an opt-in toggle to consume shop offcuts first, and inventory workflows. header = QHBoxLayout() header.addWidget(QLabel("Build units:")) self._qty_spin = QSpinBox() @@ -102,16 +107,83 @@ class BomWindow(QDialog): "carry across units") self._qty_spin.valueChanged.connect(self._on_quantity_changed) header.addWidget(self._qty_spin) + self._offcut_chk = QCheckBox("Use shop offcuts") + self._offcut_chk.setToolTip("Consume offcuts you already have before buying new stock") + self._offcut_chk.toggled.connect(self._on_use_offcuts) + header.addWidget(self._offcut_chk) header.addStretch() + buy = QPushButton("Mark purchased…") + buy.setToolTip("Add this shopping list to your shop inventory") + buy.clicked.connect(self._mark_purchased) + rec = QPushButton("Record build…") + rec.setToolTip("Deduct stock, keep/discard offcuts, log the build") + rec.clicked.connect(self._record_build) + header.addWidget(buy); header.addWidget(rec) root = QVBoxLayout(self) root.addLayout(header) root.addWidget(tabs) self._refresh_all() + def _available(self): + if not self._use_offcuts: + return None + return self._ledger.available_stock(self._plan.settings.stick_len_in) + + def _rebuild_base(self) -> None: + self._optimized = False + self._set_plan(build_cut_plan(self.c.scene, quantity=self._quantity, + available=self._available())) + def _on_quantity_changed(self, value: int) -> None: self._quantity = max(1, value) - self._set_plan(build_cut_plan(self.c.scene, quantity=self._quantity)) + self._rebuild_base() + + def _on_use_offcuts(self, on: bool) -> None: + self._use_offcuts = on + self._rebuild_base() + + def _mark_purchased(self) -> None: + bought = Counter(sp.stock for sp in self._plan.stock_pieces + if not getattr(sp, "owned", False)) + if not bought: + QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.") + return + dlg = PurchaseDialog(bought, self._prices, self) + if not dlg.exec(): + return + rows, save = dlg.result() + for stock, qty, price in rows: + self._ledger.purchase(stock, qty, price=price) + if save: + self._prices[stock] = price + self._ledger.save() + if save: + prices_mod.save_prices(self._prices) + self._cost_te.setPlainText(self._cost_text()) + QMessageBox.information(self, "Added to inventory", + f"Added {sum(q for _, q, _ in rows)} item(s) to shop inventory.") + + def _record_build(self) -> None: + consumed, offcuts = plan_consumption(self._plan) + used = [sp.source for sp in self._plan.stock_pieces + if getattr(sp, "owned", False) and sp.source] + dlg = RecordBuildDialog(consumed, offcuts, self._quantity, self) + if not dlg.exec(): + return + for oid in used: + self._ledger.consume_offcut(oid) + cost = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, + self._rates, quantity=self._quantity).total_cost + project = getattr(self.c, "scene_path", None) + project = project.stem if project else "project" + self._ledger.record_build(project, self._quantity, consumed, offcuts, + dispositions=dlg.dispositions(), cost=cost) + self._ledger.save() + if self._use_offcuts: + self._rebuild_base() + QMessageBox.information(self, "Build recorded", + "Recorded in shop inventory (File ▸ Inventory to view).") # ----- one active plan; all tabs render from it --------------------- def _set_plan(self, plan) -> None: @@ -171,12 +243,16 @@ class BomWindow(QDialog): def _shop_text(self) -> str: plan = self._plan lines = ["SHOPPING LIST", "", "Buy:"] - for stock, qty in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()): + bought = [sp for sp in plan.stock_pieces if not getattr(sp, "owned", False)] + for stock, qty in sorted(Counter(sp.stock for sp in bought).items()): s = "s" if qty != 1 else "" unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')" lines.append(f" {qty} × {stock} {unit}") - if not plan.stock_pieces: - lines.append(" (nothing yet)") + if not bought: + lines.append(" (nothing to buy)") + owned = [sp for sp in plan.stock_pieces if getattr(sp, "owned", False)] + if owned: + lines += ["", f"Using {len(owned)} offcut(s) from your shop inventory."] if plan.unplaced: lines += ["", "⚠ Won't fit standard stock — source / cut specially:"] for iid in plan.unplaced: @@ -701,3 +777,81 @@ class RatesEditDialog(QDialog): for key, sp in spins.items(): d[key] = sp.value() return self._rates + + +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).""" + + def __init__(self, bought, prices, parent=None): + super().__init__(parent) + self.setWindowTitle("Mark purchased") + self.resize(360, 320) + v = QVBoxLayout(self) + v.addWidget(QLabel("Add these to your shop inventory?")) + rows = sorted(bought.items()) + self._table = QTableWidget(len(rows), 3) + self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"]) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self._spins = [] + for r, (stock, qty) in enumerate(rows): + name = QTableWidgetItem(stock) + name.setFlags(name.flags() & ~Qt.ItemIsEditable) + self._table.setItem(r, 0, name) + q = QSpinBox(); q.setRange(0, 9999); q.setValue(int(qty)) + p = QDoubleSpinBox(); p.setRange(0.0, 100000.0); p.setDecimals(2) + p.setValue(float(prices.get(stock, 0.0))) + self._table.setCellWidget(r, 1, q) + self._table.setCellWidget(r, 2, p) + self._spins.append((stock, q, p)) + v.addWidget(self._table) + 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 result(self): + rows = [(stock, q.value(), round(p.value(), 2)) + for stock, q, p in self._spins if q.value() > 0] + return rows, self._save.isChecked() + + +class RecordBuildDialog(QDialog): + """Confirm what stock was consumed and decide each offcut's fate before the + build is committed to inventory (the moment to correct reality).""" + + _FATES = ["keep", "burned", "trashed", "ignore"] + + def __init__(self, consumed, offcuts, quantity, parent=None): + super().__init__(parent) + self.setWindowTitle("Record build") + self.resize(420, 420) + v = QVBoxLayout(self) + units = f" ×{quantity}" if quantity > 1 else "" + v.addWidget(QLabel(f"Recording a build{units}.\n\nConsumed from stock:")) + cons = ", ".join(f"{q} × {s}" for s, q in sorted(consumed.items())) or "(none)" + lbl = QLabel(" " + cons); lbl.setWordWrap(True) + v.addWidget(lbl) + v.addWidget(QLabel("Offcuts produced — keep, burn, trash, or ignore each:")) + self._table = QTableWidget(len(offcuts), 2) + self._table.setHorizontalHeaderLabels(["Offcut", "Fate"]) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self._combos = [] + for r, oc in enumerate(offcuts): + unit = "sheet" if oc["is_sheet"] else "stick" + desc = (f"{oc['stock']} {oc['length_in']:g}\" × {oc['width_in']:g}\"" + if oc["is_sheet"] else f"{oc['stock']} {oc['length_in']:g}\" {unit}") + name = QTableWidgetItem(desc) + name.setFlags(name.flags() & ~Qt.ItemIsEditable) + self._table.setItem(r, 0, name) + combo = QComboBox(); combo.addItems(self._FATES) + self._table.setCellWidget(r, 1, combo) + self._combos.append(combo) + v.addWidget(self._table) + bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + bb.accepted.connect(self.accept); bb.rejected.connect(self.reject) + v.addWidget(bb) + + def dispositions(self): + return [c.currentText() for c in self._combos] diff --git a/src/woodshop/inventory.py b/src/woodshop/inventory.py index ec88440..7414769 100644 --- a/src/woodshop/inventory.py +++ b/src/woodshop/inventory.py @@ -25,7 +25,7 @@ from collections import Counter from dataclasses import asdict, dataclass, field from pathlib import Path -from .lumber import is_plywood, normalize_stock +from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, actual_section, is_plywood, normalize_stock @dataclass @@ -51,7 +51,7 @@ def _data_path() -> Path: def plan_consumption(plan) -> tuple[dict, list[dict]]: """From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts). Offcuts are the reusable waste regions; ids are assigned later by the ledger.""" - consumed = Counter(sp.stock for sp in plan.stock_pieces) + consumed = Counter(sp.stock for sp in plan.stock_pieces if not getattr(sp, "owned", False)) offcuts = [] for sp in plan.stock_pieces: for w in sp.waste: @@ -114,26 +114,27 @@ class Ledger: self._emit("consume", offcut_id=offcut_id) def record_build(self, project: str, units: int, consumed: dict, - offcuts: list[dict], keep_ids=None, fates: dict | None = None, + offcuts: list[dict], dispositions: list | None = None, cost: float | None = None, date: str = "") -> str: """Record a build: deduct consumed full stock, then for each produced - offcut either keep it (create_offcut) or discard it (burned/trashed). - `keep_ids`/`fates` are keyed by the offcut's assigned id (oc index order). - Returns the build id.""" + offcut apply its disposition. `dispositions[i]` is one of "keep" (→ offcut + bin), "burned"/"trashed" (→ discard), or "ignore" (don't track). Defaults + to keeping every offcut. Returns the build id.""" build_id = self._next_build_id() for stock, qty in consumed.items(): if qty: self._emit("consume", stock=normalize_stock(stock), qty=int(qty), build_id=build_id) - keep = set(keep_ids) if keep_ids is not None else set() - fates = fates or {} + disp = dispositions if dispositions is not None else ["keep"] * len(offcuts) for i, oc in enumerate(offcuts): + fate = disp[i] if i < len(disp) else "keep" oid = f"{build_id}-o{i + 1}" - if oid in keep or (keep_ids is None): # default: keep all offcuts + if fate == "keep": self._emit("create_offcut", offcut={**oc, "id": oid, "source_project": project, "source_build": build_id}) - else: - self._emit("discard", offcut_id=oid, fate=fates.get(oid, "trashed")) + elif fate in ("burned", "trashed"): + self._emit("discard", offcut_id=oid, fate=fate) + # "ignore" -> not tracked self._emit("build_recorded", build_id=build_id, project=project, units=int(units), cost=cost, date=date) return build_id @@ -165,14 +166,19 @@ class Ledger: source_project=oc.get("source_project", ""), bin=oc.get("bin", "")) for oc in live.values()] - def available_stock(self) -> list[Piece]: - """Everything the planner could consume: full on-hand units + offcuts.""" + def available_stock(self, stick_len_in: float = 96.0) -> list[Piece]: + """Everything the planner could consume: full on-hand units (given real + dimensions so they pack) + offcuts.""" pieces = list(self.offcut_bin()) for stock, qty in self.on_hand().items(): + sheet = is_plywood(stock) + if sheet: + L, W = SHEET_LENGTH_IN, SHEET_WIDTH_IN + else: + L, W = stick_len_in, actual_section(stock)[1] for i in range(qty): pieces.append(Piece(id=f"{stock}#{i + 1}", stock=stock, - length_in=0.0, width_in=0.0, - is_sheet=is_plywood(stock))) + length_in=L, width_in=W, is_sheet=sheet)) return pieces def builds(self) -> list[dict]: diff --git a/src/woodshop/prices.py b/src/woodshop/prices.py index 5f1032a..196d4f0 100644 --- a/src/woodshop/prices.py +++ b/src/woodshop/prices.py @@ -118,7 +118,8 @@ def estimate(plan, prices: dict[str, float] | None = None, hst: float = NB_HST) 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) + counts = Counter(normalize_stock(sp.stock) for sp in plan.stock_pieces + if not getattr(sp, "owned", False)) # offcuts are free, not bought lines = [] for stock, qty in sorted(counts.items()): diff --git a/tests/test_bom_window.py b/tests/test_bom_window.py index 2792c69..708f1ed 100644 --- a/tests/test_bom_window.py +++ b/tests/test_bom_window.py @@ -125,3 +125,34 @@ def test_valid_move_commits(tmp_path): second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear w._drop_piece(second, home) assert "placed" in w._status.text().lower() + + +def test_offcut_toggle_uses_inventory(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) + from woodshop import inventory as I + led = I.Ledger() + led.record_build("seed", 1, consumed={}, offcuts=[ + {"stock": "2x4", "length_in": 96, "width_in": 3.5, "is_sheet": False}]) + led.save() + c = Controller(str(tmp_path / "s.json")) + c.place("2x4", 30) + w = BomWindow(c) + assert w._plan.score["stock_count"] == 1 # buys 1 stick by default + w._offcut_chk.setChecked(True) # use the 96" offcut instead + assert w._plan.score["stock_count"] == 0 + assert w._plan.score["owned_count"] == 1 + + +def test_record_build_writes_ledger(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) + from woodshop import inventory as I + c = Controller(str(tmp_path / "s.json")) + c.place("2x4", 60) + w = BomWindow(c) + consumed, offcuts = I.plan_consumption(w._plan) + w._ledger.record_build("t", 1, consumed, offcuts, dispositions=["keep"] * len(offcuts)) + w._ledger.save() + again = I.Ledger.load() + assert again.builds() and again.stats()["units_built"] == 1 diff --git a/tests/test_inventory.py b/tests/test_inventory.py index f063227..0a79d26 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -25,7 +25,7 @@ def test_record_build_keeps_and_discards_offcuts(): offcuts = [{"stock": "2x4", "length_in": 20, "width_in": 3.5, "is_sheet": False}, {"stock": "2x4", "length_in": 8, "width_in": 3.5, "is_sheet": False}] bid = led.record_build("shelf", 1, consumed={"2x4": 1}, offcuts=offcuts, - keep_ids={f"{ 'b1'}-o1"}, fates={"b1-o2": "burned"}) + dispositions=["keep", "burned"]) bin_ = led.offcut_bin() assert len(bin_) == 1 and bin_[0].id == "b1-o1" # kept the 20" stats = led.stats() @@ -77,3 +77,17 @@ def test_stats_aggregate_across_projects(): st = led.stats() assert st["units_built"] == 3 and st["builds"] == 2 assert st["by_project"] == {"table": 2, "shelf": 1} + + +def test_planner_consumes_offcuts_before_buying(): + from woodshop.inventory import Piece + s = Scene() + s.place("2x4", 30) + # one 96" offcut on hand → the 30" piece should use it, buying 0 new sticks + offcut = Piece(id="oc1", stock="2x4", length_in=96, width_in=3.5, + is_sheet=False, is_offcut=True) + plan = build_cut_plan(s, available=[offcut]) + assert plan.score["stock_count"] == 0 # bought nothing + assert plan.score["owned_count"] == 1 + owned = [sp for sp in plan.stock_pieces if sp.owned] + assert owned and owned[0].placements # the 30" sits in the offcut