Phase 6: inventory workflows (purchase / record build / use offcuts)
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>
This commit is contained in:
parent
30a10adabc
commit
2b76317a3f
|
|
@ -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:
|
||||
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,13 +500,16 @@ 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:
|
||||
if not sp.owned:
|
||||
bought_area += sp.length_in * sp.width_in
|
||||
waste_area += sp.length_in * sp.width_in - used
|
||||
else:
|
||||
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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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()):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue