Fix material/inventory boundary + offcut-preservation (Codex review)
1. Offcut reuse was lost on optimize: Find better layout / Best of 100 / Try alternative now pass available=self._available(); reoptimize seeds preserve owned/source so a locked offcut stays owned (not silently bought). 2. Inventory is now species-aware end to end: purchase/consume/adjust/on_hand/ available_stock and record_build key by (stock, material); plan_consumption and Mark-purchased group by species; PurchaseDialog shows species and prices at the species rate; price-book save backs out the multiplier to the SPF base. A spruce on-hand no longer satisfies an oak cut. 3. Cross-species placement is now invalid: validate_cut_plan and the GUI drop path reject an oak cut on a spruce piece. 4. Yield is bought-only and consistent: _score divides bought-used by bought-area (owned offcuts excluded); the Shopping tab's yield matches. tests: locked-reopt keeps owned offcut, species-aware on-hand, cross-species validate, yield excludes owned, optimize preserves the offcut toggle. 203 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
36d02fcb73
commit
01c4dee0bc
|
|
@ -511,12 +511,13 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||||
|
|
||||||
|
|
||||||
def _score(stock_pieces, s, strategy, warnings) -> dict:
|
def _score(stock_pieces, s, strategy, warnings) -> dict:
|
||||||
waste_area = used_area = bought_area = 0.0
|
waste_area = bought_used = bought_area = 0.0
|
||||||
reusable = 0
|
reusable = 0
|
||||||
bought = [sp for sp in stock_pieces if not sp.owned]
|
bought = [sp for sp in stock_pieces if not sp.owned]
|
||||||
for sp in stock_pieces:
|
for sp in stock_pieces:
|
||||||
used = sum(p.len_in * p.wid_in for p in sp.placements)
|
used = sum(p.len_in * p.wid_in for p in sp.placements)
|
||||||
used_area += used
|
if not sp.owned:
|
||||||
|
bought_used += used # yield is about BOUGHT stock only
|
||||||
if sp.is_sheet:
|
if sp.is_sheet:
|
||||||
if not sp.owned:
|
if not sp.owned:
|
||||||
bought_area += sp.length_in * sp.width_in
|
bought_area += sp.length_in * sp.width_in
|
||||||
|
|
@ -537,7 +538,7 @@ def _score(stock_pieces, s, strategy, warnings) -> dict:
|
||||||
"waste_area": round(waste_area, 1),
|
"waste_area": round(waste_area, 1),
|
||||||
"reusable_offcuts": reusable,
|
"reusable_offcuts": reusable,
|
||||||
"reusable_in": round(reusable_in, 1),
|
"reusable_in": round(reusable_in, 1),
|
||||||
"yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0,
|
"yield_pct": round(bought_used / bought_area * 100, 1) if bought_area else 0.0,
|
||||||
"warnings": list(warnings),
|
"warnings": list(warnings),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -587,6 +588,7 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
|
||||||
seeds.setdefault((sp.stock, sp.material), {})[sp.id] = StockPiece(
|
seeds.setdefault((sp.stock, sp.material), {})[sp.id] = StockPiece(
|
||||||
id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet,
|
id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet,
|
||||||
length_in=sp.length_in, width_in=sp.width_in, material=sp.material,
|
length_in=sp.length_in, width_in=sp.width_in, material=sp.material,
|
||||||
|
owned=sp.owned, source=sp.source,
|
||||||
placements=[Placement(id=p.id, item_id=p.item_id, x_in=p.x_in, y_in=p.y_in,
|
placements=[Placement(id=p.id, item_id=p.item_id, x_in=p.x_in, y_in=p.y_in,
|
||||||
len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True)
|
len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True)
|
||||||
for p in kept])
|
for p in kept])
|
||||||
|
|
@ -773,6 +775,8 @@ def validate_cut_plan(plan: CutPlan) -> list:
|
||||||
it = items.get(p.item_id)
|
it = items.get(p.item_id)
|
||||||
if it and it.stock != sp.stock:
|
if it and it.stock != sp.stock:
|
||||||
problems.append(f"{p.id} ({it.stock}) is on a {sp.stock} stock piece")
|
problems.append(f"{p.id} ({it.stock}) is on a {sp.stock} stock piece")
|
||||||
|
elif it and it.material and sp.material and it.material != sp.material:
|
||||||
|
problems.append(f"{p.id} ({it.material}) is on a {sp.material} stock piece")
|
||||||
if p.rotated and not rot_ok:
|
if p.rotated and not rot_ok:
|
||||||
problems.append(f"{p.id} is rotated but rotation isn't allowed")
|
problems.append(f"{p.id} is rotated but rotation isn't allowed")
|
||||||
ps = sp.placements
|
ps = sp.placements
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ class BomWindow(QDialog):
|
||||||
self._rebuild_base()
|
self._rebuild_base()
|
||||||
|
|
||||||
def _mark_purchased(self) -> None:
|
def _mark_purchased(self) -> None:
|
||||||
bought = Counter(sp.stock for sp in self._plan.stock_pieces
|
bought = Counter((sp.stock, sp.material) for sp in self._plan.stock_pieces
|
||||||
if not getattr(sp, "owned", False))
|
if not getattr(sp, "owned", False))
|
||||||
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.")
|
||||||
|
|
@ -153,16 +153,19 @@ class BomWindow(QDialog):
|
||||||
if not dlg.exec():
|
if not dlg.exec():
|
||||||
return
|
return
|
||||||
rows, save = dlg.result()
|
rows, save = dlg.result()
|
||||||
for stock, qty, price in rows:
|
mults = prices_mod.load_material_multipliers()
|
||||||
self._ledger.purchase(stock, qty, price=price)
|
for stock, material, qty, price in rows:
|
||||||
|
self._ledger.purchase(stock, qty, material=material, price=price)
|
||||||
if save:
|
if save:
|
||||||
self._prices[stock] = price
|
# price book stores the SPF base; back out the species multiplier
|
||||||
|
mult = mults.get(material, 1.0) or 1.0
|
||||||
|
self._prices[stock] = round(price / mult, 2)
|
||||||
self._ledger.save()
|
self._ledger.save()
|
||||||
if save:
|
if save:
|
||||||
prices_mod.save_prices(self._prices)
|
prices_mod.save_prices(self._prices)
|
||||||
self._cost_te.setPlainText(self._cost_text())
|
self._cost_te.setPlainText(self._cost_text())
|
||||||
QMessageBox.information(self, "Added to inventory",
|
QMessageBox.information(self, "Added to inventory",
|
||||||
f"Added {sum(q for _, q, _ in rows)} item(s) to shop inventory.")
|
f"Added {sum(q for *_, q, _ in rows)} item(s) to shop inventory.")
|
||||||
|
|
||||||
def _record_build(self) -> None:
|
def _record_build(self) -> None:
|
||||||
consumed, offcuts = plan_consumption(self._plan)
|
consumed, offcuts = plan_consumption(self._plan)
|
||||||
|
|
@ -261,8 +264,8 @@ class BomWindow(QDialog):
|
||||||
it = plan.item(iid)
|
it = plan.item(iid)
|
||||||
lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}")
|
lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}")
|
||||||
lines += ["", "Yield (used / bought):"]
|
lines += ["", "Yield (used / bought):"]
|
||||||
for stock in sorted({sp.stock for sp in plan.stock_pieces}):
|
for stock in sorted({sp.stock for sp in bought}): # bought only — matches score
|
||||||
sps = [sp for sp in plan.stock_pieces if sp.stock == stock]
|
sps = [sp for sp in bought if sp.stock == stock]
|
||||||
sheet = sps[0].is_sheet
|
sheet = sps[0].is_sheet
|
||||||
used = sum(p.len_in * p.wid_in for sp in sps for p in sp.placements)
|
used = sum(p.len_in * p.wid_in for sp in sps for p in sp.placements)
|
||||||
cap = sum(sp.length_in * sp.width_in for sp in sps)
|
cap = sum(sp.length_in * sp.width_in for sp in sps)
|
||||||
|
|
@ -500,7 +503,8 @@ class BomWindow(QDialog):
|
||||||
self._set_plan(best)
|
self._set_plan(best)
|
||||||
self._status.setText("✓ optimized around locked pieces")
|
self._status.setText("✓ optimized around locked pieces")
|
||||||
else:
|
else:
|
||||||
self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity))
|
self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity,
|
||||||
|
available=self._available()))
|
||||||
self._status.setText("✓ optimized")
|
self._status.setText("✓ optimized")
|
||||||
|
|
||||||
def _best_of_n(self) -> None:
|
def _best_of_n(self) -> None:
|
||||||
|
|
@ -513,7 +517,8 @@ class BomWindow(QDialog):
|
||||||
self._set_plan(best)
|
self._set_plan(best)
|
||||||
self._status.setText("✓ best of 100 around locked pieces")
|
self._status.setText("✓ best of 100 around locked pieces")
|
||||||
else:
|
else:
|
||||||
self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity))
|
self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity,
|
||||||
|
available=self._available()))
|
||||||
self._status.setText("✓ best of 100 attempts")
|
self._status.setText("✓ best of 100 attempts")
|
||||||
|
|
||||||
def _next_arrangement(self) -> None:
|
def _next_arrangement(self) -> None:
|
||||||
|
|
@ -521,7 +526,8 @@ class BomWindow(QDialog):
|
||||||
self._order = (self._order + 1) % len(STRATEGIES)
|
self._order = (self._order + 1) % len(STRATEGIES)
|
||||||
st = STRATEGIES[self._order]
|
st = STRATEGIES[self._order]
|
||||||
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks()
|
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks()
|
||||||
else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity))
|
else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity,
|
||||||
|
available=self._available()))
|
||||||
self._set_plan(plan)
|
self._set_plan(plan)
|
||||||
|
|
||||||
def _draw_layout(self) -> None:
|
def _draw_layout(self) -> None:
|
||||||
|
|
@ -600,12 +606,20 @@ class BomWindow(QDialog):
|
||||||
if target is None:
|
if target is None:
|
||||||
target = sp_cur
|
target = sp_cur
|
||||||
# Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc.
|
# Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc.
|
||||||
item_stock = plan.item(p.item_id).stock
|
cut_item = plan.item(p.item_id)
|
||||||
|
item_stock = cut_item.stock
|
||||||
if item_stock != target.stock:
|
if item_stock != target.stock:
|
||||||
self._revert(plan, item.pid, home)
|
self._revert(plan, item.pid, home)
|
||||||
self._status.setText(f"✗ {item_stock} can't go on {target.stock} — reverted")
|
self._status.setText(f"✗ {item_stock} can't go on {target.stock} — reverted")
|
||||||
recompute(plan); self._refresh_all()
|
recompute(plan); self._refresh_all()
|
||||||
return
|
return
|
||||||
|
# Species compatibility: an oak cut can't go on a spruce stick.
|
||||||
|
if (cut_item.material and target.material and cut_item.material != target.material):
|
||||||
|
self._revert(plan, item.pid, home)
|
||||||
|
self._status.setText(f"✗ {cut_item.material} can't go on "
|
||||||
|
f"{target.material} stock — reverted")
|
||||||
|
recompute(plan); self._refresh_all()
|
||||||
|
return
|
||||||
row_y0 = self._row_of(target.id)
|
row_y0 = self._row_of(target.id)
|
||||||
x_in = max(item.pos().x() / px, 0.0)
|
x_in = max(item.pos().x() / px, 0.0)
|
||||||
y_in = max((item.pos().y() - row_y0) / px, 0.0) if target.is_sheet else 0.0
|
y_in = max((item.pos().y() - row_y0) / px, 0.0) if target.is_sheet else 0.0
|
||||||
|
|
@ -803,8 +817,10 @@ class PurchaseDialog(QDialog):
|
||||||
|
|
||||||
def __init__(self, bought, prices, parent=None):
|
def __init__(self, bought, prices, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
from ..lumber import default_material
|
||||||
self.setWindowTitle("Mark purchased")
|
self.setWindowTitle("Mark purchased")
|
||||||
self.resize(360, 320)
|
self.resize(380, 320)
|
||||||
|
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?"))
|
||||||
rows = sorted(bought.items())
|
rows = sorted(bought.items())
|
||||||
|
|
@ -812,16 +828,18 @@ 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 = []
|
||||||
for r, (stock, qty) in enumerate(rows):
|
for r, ((stock, material), qty) in enumerate(rows):
|
||||||
name = QTableWidgetItem(stock)
|
label = (f"{material} {stock}" if material and material != default_material(stock)
|
||||||
|
else stock)
|
||||||
|
name = QTableWidgetItem(label)
|
||||||
name.setFlags(name.flags() & ~Qt.ItemIsEditable)
|
name.setFlags(name.flags() & ~Qt.ItemIsEditable)
|
||||||
self._table.setItem(r, 0, name)
|
self._table.setItem(r, 0, name)
|
||||||
q = QSpinBox(); q.setRange(0, 9999); q.setValue(int(qty))
|
q = QSpinBox(); q.setRange(0, 9999); q.setValue(int(qty))
|
||||||
p = QDoubleSpinBox(); p.setRange(0.0, 100000.0); p.setDecimals(2)
|
p = QDoubleSpinBox(); p.setRange(0.0, 100000.0); p.setDecimals(2)
|
||||||
p.setValue(float(prices.get(stock, 0.0)))
|
p.setValue(round(float(prices.get(stock, 0.0)) * (mults.get(material, 1.0) or 1.0), 2))
|
||||||
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, q, p))
|
self._spins.append((stock, material, q, p))
|
||||||
v.addWidget(self._table)
|
v.addWidget(self._table)
|
||||||
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)
|
||||||
|
|
@ -830,8 +848,8 @@ class PurchaseDialog(QDialog):
|
||||||
v.addWidget(bb)
|
v.addWidget(bb)
|
||||||
|
|
||||||
def result(self):
|
def result(self):
|
||||||
rows = [(stock, q.value(), round(p.value(), 2))
|
rows = [(stock, material, q.value(), round(p.value(), 2))
|
||||||
for stock, q, p in self._spins if q.value() > 0]
|
for stock, material, q, p in self._spins if q.value() > 0]
|
||||||
return rows, self._save.isChecked()
|
return rows, self._save.isChecked()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,12 @@ class InventoryWindow(QDialog):
|
||||||
oh = self._ledger.on_hand()
|
oh = self._ledger.on_hand()
|
||||||
if not oh:
|
if not oh:
|
||||||
return "ON-HAND STOCK\n\n (empty — Mark purchased on the BOM window to add)"
|
return "ON-HAND STOCK\n\n (empty — Mark purchased on the BOM window to add)"
|
||||||
|
from ..lumber import default_material
|
||||||
lines = ["ON-HAND STOCK", ""]
|
lines = ["ON-HAND STOCK", ""]
|
||||||
for stock, qty in oh.items():
|
for (stock, material), qty in oh.items():
|
||||||
unit = "sheet" if stock.startswith("ply-") else "stick"
|
unit = "sheet" if stock.startswith("ply-") else "stick"
|
||||||
lines.append(f" {qty:>3} × {stock:<10} ({unit}s)")
|
name = f"{material} {stock}" if material and material != default_material(stock) else stock
|
||||||
|
lines.append(f" {qty:>3} × {name:<14} ({unit}s)")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _offcuts_text(self) -> str:
|
def _offcuts_text(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ from collections import Counter
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, actual_section, is_plywood, normalize_stock
|
from .lumber import (SHEET_LENGTH_IN, SHEET_WIDTH_IN, actual_section, default_material,
|
||||||
|
is_plywood, normalize_stock)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -51,7 +52,8 @@ def _data_path() -> Path:
|
||||||
def plan_consumption(plan) -> tuple[dict, list[dict]]:
|
def plan_consumption(plan) -> tuple[dict, list[dict]]:
|
||||||
"""From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts).
|
"""From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts).
|
||||||
Offcuts are the reusable waste regions; ids are assigned later by the ledger."""
|
Offcuts are the reusable waste regions; ids are assigned later by the ledger."""
|
||||||
consumed = Counter(sp.stock for sp in plan.stock_pieces if not getattr(sp, "owned", False))
|
consumed = Counter((sp.stock, getattr(sp, "material", "") or default_material(sp.stock))
|
||||||
|
for sp in plan.stock_pieces if not getattr(sp, "owned", False))
|
||||||
offcuts = []
|
offcuts = []
|
||||||
for sp in plan.stock_pieces:
|
for sp in plan.stock_pieces:
|
||||||
for w in sp.waste:
|
for w in sp.waste:
|
||||||
|
|
@ -97,16 +99,18 @@ class Ledger:
|
||||||
return f"b{n}"
|
return f"b{n}"
|
||||||
|
|
||||||
# ----- primitives (each appends events) -----------------------------
|
# ----- primitives (each appends events) -----------------------------
|
||||||
def purchase(self, stock: str, qty: int, price: float | None = None,
|
def purchase(self, stock: str, qty: int, material: str = "", price: float | None = None,
|
||||||
date: str = "", is_sheet: bool | None = None) -> None:
|
date: str = "", is_sheet: bool | None = None) -> None:
|
||||||
stock = normalize_stock(stock)
|
stock = normalize_stock(stock)
|
||||||
sheet = is_plywood(stock) if is_sheet is None else is_sheet
|
sheet = is_plywood(stock) if is_sheet is None else is_sheet
|
||||||
self._emit("purchase", stock=stock, qty=int(qty), is_sheet=sheet,
|
self._emit("purchase", stock=stock, material=material or default_material(stock),
|
||||||
price=price, date=date)
|
qty=int(qty), is_sheet=sheet, price=price, date=date)
|
||||||
|
|
||||||
def adjust(self, stock: str, delta: int, reason: str = "", date: str = "") -> None:
|
def adjust(self, stock: str, delta: int, material: str = "", reason: str = "",
|
||||||
self._emit("adjustment", stock=normalize_stock(stock), delta=int(delta),
|
date: str = "") -> None:
|
||||||
reason=reason, date=date)
|
stock = normalize_stock(stock)
|
||||||
|
self._emit("adjustment", stock=stock, material=material or default_material(stock),
|
||||||
|
delta=int(delta), reason=reason, date=date)
|
||||||
|
|
||||||
def discard_offcut(self, offcut_id: str, fate: str, date: str = "") -> None:
|
def discard_offcut(self, offcut_id: str, fate: str, date: str = "") -> None:
|
||||||
self._emit("discard", offcut_id=offcut_id, fate=fate, date=date)
|
self._emit("discard", offcut_id=offcut_id, fate=fate, date=date)
|
||||||
|
|
@ -122,10 +126,13 @@ class Ledger:
|
||||||
bin), "burned"/"trashed" (→ discard), or "ignore" (don't track). Defaults
|
bin), "burned"/"trashed" (→ discard), or "ignore" (don't track). Defaults
|
||||||
to keeping every offcut. Returns the build id."""
|
to keeping every offcut. Returns the build id."""
|
||||||
build_id = self._next_build_id()
|
build_id = self._next_build_id()
|
||||||
for stock, qty in consumed.items():
|
for key, qty in consumed.items():
|
||||||
if qty:
|
if not qty:
|
||||||
self._emit("consume", stock=normalize_stock(stock), qty=int(qty),
|
continue
|
||||||
build_id=build_id)
|
stock, material = key if isinstance(key, tuple) else (key, default_material(key))
|
||||||
|
self._emit("consume", stock=normalize_stock(stock),
|
||||||
|
material=material or default_material(stock), qty=int(qty),
|
||||||
|
build_id=build_id)
|
||||||
disp = dispositions if dispositions is not None else ["keep"] * len(offcuts)
|
disp = dispositions if dispositions is not None else ["keep"] * len(offcuts)
|
||||||
for i, oc in enumerate(offcuts):
|
for i, oc in enumerate(offcuts):
|
||||||
fate = disp[i] if i < len(disp) else "keep"
|
fate = disp[i] if i < len(disp) else "keep"
|
||||||
|
|
@ -142,14 +149,19 @@ class Ledger:
|
||||||
|
|
||||||
# ----- derived state (fold the events) ------------------------------
|
# ----- derived state (fold the events) ------------------------------
|
||||||
def on_hand(self) -> dict:
|
def on_hand(self) -> dict:
|
||||||
|
"""Full units on hand, keyed by (stock, material)."""
|
||||||
c = Counter()
|
c = Counter()
|
||||||
|
|
||||||
|
def key(e):
|
||||||
|
return (e["stock"], e.get("material") or default_material(e["stock"]))
|
||||||
|
|
||||||
for e in self.events:
|
for e in self.events:
|
||||||
if e["type"] == "purchase":
|
if e["type"] == "purchase":
|
||||||
c[e["stock"]] += e["qty"]
|
c[key(e)] += e["qty"]
|
||||||
elif e["type"] == "consume" and "stock" in e:
|
elif e["type"] == "consume" and "stock" in e:
|
||||||
c[e["stock"]] -= e["qty"]
|
c[key(e)] -= e["qty"]
|
||||||
elif e["type"] == "adjustment":
|
elif e["type"] == "adjustment":
|
||||||
c[e["stock"]] += e["delta"]
|
c[key(e)] += e["delta"]
|
||||||
return {k: v for k, v in sorted(c.items()) if v}
|
return {k: v for k, v in sorted(c.items()) if v}
|
||||||
|
|
||||||
def offcut_bin(self) -> list[Piece]:
|
def offcut_bin(self) -> list[Piece]:
|
||||||
|
|
@ -172,15 +184,15 @@ class Ledger:
|
||||||
"""Everything the planner could consume: full on-hand units (given real
|
"""Everything the planner could consume: full on-hand units (given real
|
||||||
dimensions so they pack) + offcuts."""
|
dimensions so they pack) + offcuts."""
|
||||||
pieces = list(self.offcut_bin())
|
pieces = list(self.offcut_bin())
|
||||||
for stock, qty in self.on_hand().items():
|
for (stock, material), qty in self.on_hand().items():
|
||||||
sheet = is_plywood(stock)
|
sheet = is_plywood(stock)
|
||||||
if sheet:
|
if sheet:
|
||||||
L, W = SHEET_LENGTH_IN, SHEET_WIDTH_IN
|
L, W = SHEET_LENGTH_IN, SHEET_WIDTH_IN
|
||||||
else:
|
else:
|
||||||
L, W = stick_len_in, actual_section(stock)[1]
|
L, W = stick_len_in, actual_section(stock)[1]
|
||||||
for i in range(qty):
|
for i in range(qty):
|
||||||
pieces.append(Piece(id=f"{stock}#{i + 1}", stock=stock,
|
pieces.append(Piece(id=f"{stock}:{material}#{i + 1}", stock=stock,
|
||||||
length_in=L, width_in=W, is_sheet=sheet))
|
length_in=L, width_in=W, is_sheet=sheet, material=material))
|
||||||
return pieces
|
return pieces
|
||||||
|
|
||||||
def builds(self) -> list[dict]:
|
def builds(self) -> list[dict]:
|
||||||
|
|
|
||||||
|
|
@ -156,3 +156,22 @@ def test_record_build_writes_ledger(tmp_path, monkeypatch):
|
||||||
w._ledger.save()
|
w._ledger.save()
|
||||||
again = I.Ledger.load()
|
again = I.Ledger.load()
|
||||||
assert again.builds() and again.stats()["units_built"] == 1
|
assert again.builds() and again.stats()["units_built"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_preserves_offcut_toggle(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)
|
||||||
|
w._offcut_chk.setChecked(True)
|
||||||
|
assert w._plan.score["stock_count"] == 0
|
||||||
|
w._optimize() # Codex #1: must NOT lose the offcut
|
||||||
|
assert w._plan.score["stock_count"] == 0
|
||||||
|
w._best_of_n()
|
||||||
|
assert w._plan.score["stock_count"] == 0
|
||||||
|
|
|
||||||
|
|
@ -357,3 +357,46 @@ def test_batch_estimate_per_unit():
|
||||||
assert est.quantity == 4
|
assert est.quantity == 4
|
||||||
assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01
|
assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01
|
||||||
assert abs(est.per_unit_price - est.price / 4) < 0.01
|
assert abs(est.per_unit_price - est.price / 4) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_locked_reoptimize_preserves_owned_offcut():
|
||||||
|
"""Codex #1: a locked offcut must stay owned through reoptimize, not become bought."""
|
||||||
|
from woodshop.cutplan import reoptimize
|
||||||
|
from woodshop.inventory import Piece
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 30)
|
||||||
|
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
|
||||||
|
owned_piece = next(sp for sp in plan.stock_pieces if sp.owned)
|
||||||
|
owned_piece.placements[0].locked = True
|
||||||
|
re = reoptimize(s, plan, "decreasing")
|
||||||
|
assert re.score["stock_count"] == 0 # still bought nothing
|
||||||
|
assert re.score["owned_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_flags_cross_species():
|
||||||
|
"""Codex #3: an oak cut on a spruce stick is invalid."""
|
||||||
|
from woodshop.cutplan import relocate
|
||||||
|
s = Scene()
|
||||||
|
s.place("1x4", 24) # spruce
|
||||||
|
s.place("1x4", 24)
|
||||||
|
s.set_material("p2", "oak")
|
||||||
|
plan = build_cut_plan(s) # two species → two pieces
|
||||||
|
spruce_sp = next(sp for sp in plan.stock_pieces if sp.material == "spruce")
|
||||||
|
oak_sp = next(sp for sp in plan.stock_pieces if sp.material == "oak")
|
||||||
|
relocate(plan, oak_sp.placements[0].id, spruce_sp.id, 40.0)
|
||||||
|
assert any("spruce stock piece" in p for p in validate_cut_plan(plan))
|
||||||
|
|
||||||
|
|
||||||
|
def test_yield_excludes_owned_offcuts():
|
||||||
|
"""Codex #4: an owned-only plan shouldn't report 0% (div by bought=0) misleadingly."""
|
||||||
|
from woodshop.inventory import Piece
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 30)
|
||||||
|
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["yield_pct"] == 0.0 # nothing bought → no bought-yield
|
||||||
|
assert plan.score["stock_count"] == 0
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,27 @@ def test_purchase_then_consume_on_hand():
|
||||||
led = Ledger()
|
led = Ledger()
|
||||||
led.purchase("2x4", 5)
|
led.purchase("2x4", 5)
|
||||||
led.purchase("ply-3/4", 2)
|
led.purchase("ply-3/4", 2)
|
||||||
assert led.on_hand() == {"2x4": 5, "ply-3/4": 2}
|
assert led.on_hand() == {("2x4", "spruce"): 5, ("ply-3/4", "spruce-ply"): 2}
|
||||||
led.record_build("table", 1, consumed={"2x4": 2}, offcuts=[])
|
led.record_build("table", 1, consumed={"2x4": 2}, offcuts=[])
|
||||||
assert led.on_hand()["2x4"] == 3
|
assert led.on_hand()[("2x4", "spruce")] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_adjustment_corrects_count():
|
def test_adjustment_corrects_count():
|
||||||
led = Ledger()
|
led = Ledger()
|
||||||
led.purchase("2x4", 5)
|
led.purchase("2x4", 5)
|
||||||
led.adjust("2x4", -1, reason="broke one")
|
led.adjust("2x4", -1, reason="broke one")
|
||||||
assert led.on_hand()["2x4"] == 4
|
assert led.on_hand()[("2x4", "spruce")] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_inventory_is_species_aware():
|
||||||
|
led = Ledger()
|
||||||
|
led.purchase("1x4", 3, material="oak")
|
||||||
|
led.purchase("1x4", 2, material="spruce")
|
||||||
|
oh = led.on_hand()
|
||||||
|
assert oh[("1x4", "oak")] == 3 and oh[("1x4", "spruce")] == 2
|
||||||
|
# consuming oak doesn't touch spruce
|
||||||
|
led.record_build("x", 1, consumed={("1x4", "oak"): 1}, offcuts=[])
|
||||||
|
assert led.on_hand()[("1x4", "oak")] == 2 and led.on_hand()[("1x4", "spruce")] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_record_build_keeps_and_discards_offcuts():
|
def test_record_build_keeps_and_discards_offcuts():
|
||||||
|
|
@ -46,7 +57,7 @@ def test_plan_consumption_from_cutplan():
|
||||||
s.place("2x4", 60) # leaves a reusable ~35" offcut on a 96" stick
|
s.place("2x4", 60) # leaves a reusable ~35" offcut on a 96" stick
|
||||||
plan = build_cut_plan(s)
|
plan = build_cut_plan(s)
|
||||||
consumed, offcuts = plan_consumption(plan)
|
consumed, offcuts = plan_consumption(plan)
|
||||||
assert consumed.get("2x4") == 1
|
assert consumed.get(("2x4", "spruce")) == 1
|
||||||
assert offcuts and offcuts[0]["stock"] == "2x4" and offcuts[0]["length_in"] > 12
|
assert offcuts and offcuts[0]["stock"] == "2x4" and offcuts[0]["length_in"] > 12
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,7 +77,7 @@ def test_ledger_save_load_roundtrip(tmp_path, monkeypatch):
|
||||||
led.purchase("2x4", 3, price=3.98)
|
led.purchase("2x4", 3, price=3.98)
|
||||||
led.save()
|
led.save()
|
||||||
again = Ledger.load()
|
again = Ledger.load()
|
||||||
assert again.on_hand()["2x4"] == 3
|
assert again.on_hand()[("2x4", "spruce")] == 3
|
||||||
assert again.stats()["spent"] == round(3 * 3.98, 2)
|
assert again.stats()["spent"] == round(3 * 3.98, 2)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,3 +102,15 @@ def test_planner_consumes_offcuts_before_buying():
|
||||||
assert plan.score["owned_count"] == 1
|
assert plan.score["owned_count"] == 1
|
||||||
owned = [sp for sp in plan.stock_pieces if sp.owned]
|
owned = [sp for sp in plan.stock_pieces if sp.owned]
|
||||||
assert owned and owned[0].placements # the 30" sits in the offcut
|
assert owned and owned[0].placements # the 30" sits in the offcut
|
||||||
|
|
||||||
|
|
||||||
|
def test_oak_project_not_satisfied_by_spruce_onhand():
|
||||||
|
"""Codex #2: a generic spruce 1x4 on-hand must NOT satisfy an oak 1x4 cut."""
|
||||||
|
led = Ledger()
|
||||||
|
led.purchase("1x4", 5, material="spruce")
|
||||||
|
s = Scene()
|
||||||
|
s.place("1x4", 30)
|
||||||
|
s.set_material("p1", "oak")
|
||||||
|
plan = build_cut_plan(s, available=led.available_stock())
|
||||||
|
assert plan.score["stock_count"] == 1 # must buy oak
|
||||||
|
assert plan.score["owned_count"] == 0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue