Phase 4: batch builds (quantity N)
Estimate N identical units, nesting all units together so offcuts carry across
units → realistic per-unit cost.
- build_cut_plan(quantity=N) / best_cut_plan(quantity=N) replicate CutItems
(not Parts) with a `unit` field; reoptimize infers the batch size from the
base plan and preserves it.
- project_estimate(quantity=N): materials from the N-unit plan; setup labor once
per batch, per-op time + consumables ×N; reports per-unit cost & price.
- BOM window: "Build units" spinner in the header drives the active plan; layout
labels pieces by unit ("U2 left leg"); cost tab shows total + per-unit.
- tests: replication + units, offcut sharing across units, per-unit estimate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7adb7e27fc
commit
59fff1cb6d
|
|
@ -59,6 +59,7 @@ class CutItem:
|
||||||
note: str = "" # e.g. "incl. tenon"
|
note: str = "" # e.g. "incl. tenon"
|
||||||
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
|
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
|
||||||
final_width_in: float = 0.0
|
final_width_in: float = 0.0
|
||||||
|
unit: int = 1 # which build unit (batch quantity > 1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def final_len(self) -> float:
|
def final_len(self) -> float:
|
||||||
|
|
@ -431,9 +432,14 @@ def _pack_plywood_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
|
||||||
|
|
||||||
|
|
||||||
def build_cut_plan(scene, settings: ShopSettings | None = None,
|
def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||||
strategy: str = "decreasing") -> CutPlan:
|
strategy: str = "decreasing", quantity: int = 1) -> CutPlan:
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
s = settings or ShopSettings()
|
s = settings or ShopSettings()
|
||||||
items = _cut_items(scene, s)
|
items = _cut_items(scene, s)
|
||||||
|
if quantity > 1: # batch: replicate cut demand per unit
|
||||||
|
items = [replace(it, id=f"{it.id}u{u}", unit=u)
|
||||||
|
for u in range(1, quantity + 1) for it in items]
|
||||||
by_id = {it.id: it for it in items}
|
by_id = {it.id: it for it in items}
|
||||||
|
|
||||||
counter = {"n": 0}
|
counter = {"n": 0}
|
||||||
|
|
@ -516,8 +522,14 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
|
||||||
"""Re-pack while PRESERVING locked placements where they sit. Unlocked pieces
|
"""Re-pack while PRESERVING locked placements where they sit. Unlocked pieces
|
||||||
are packed into the free space around locked ones first (free segments on
|
are packed into the free space around locked ones first (free segments on
|
||||||
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
|
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
s = base_plan.settings
|
s = base_plan.settings
|
||||||
items = _cut_items(scene, s)
|
items = _cut_items(scene, s)
|
||||||
|
quantity = max((getattr(it, "unit", 1) for it in base_plan.items), default=1)
|
||||||
|
if quantity > 1: # preserve the batch the base plan covered
|
||||||
|
items = [replace(it, id=f"{it.id}u{u}", unit=u)
|
||||||
|
for u in range(1, quantity + 1) for it in items]
|
||||||
locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked]
|
locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked]
|
||||||
locked_ids = {p.item_id for p in locked}
|
locked_ids = {p.item_id for p in locked}
|
||||||
counter = {"n": 0}
|
counter = {"n": 0}
|
||||||
|
|
@ -604,14 +616,15 @@ def _plan_key(plan: CutPlan):
|
||||||
STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"]
|
STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"]
|
||||||
|
|
||||||
|
|
||||||
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24) -> CutPlan:
|
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24,
|
||||||
|
quantity: int = 1) -> CutPlan:
|
||||||
"""Find a better layout by trying several strategies + shuffle restarts and
|
"""Find a better layout by trying several strategies + shuffle restarts and
|
||||||
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
|
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
|
||||||
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
|
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
|
||||||
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
|
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
|
||||||
best = None
|
best = None
|
||||||
for st in strategies:
|
for st in strategies:
|
||||||
plan = build_cut_plan(scene, settings, strategy=st)
|
plan = build_cut_plan(scene, settings, strategy=st, quantity=quantity)
|
||||||
if best is None or _plan_key(plan) < _plan_key(best):
|
if best is None or _plan_key(plan) < _plan_key(best):
|
||||||
best = plan
|
best = plan
|
||||||
if best is not None:
|
if best is not None:
|
||||||
|
|
|
||||||
|
|
@ -85,19 +85,22 @@ def save_rates(rates: EstimateRates) -> None:
|
||||||
path.write_text(json.dumps(asdict(rates), indent=2))
|
path.write_text(json.dumps(asdict(rates), indent=2))
|
||||||
|
|
||||||
|
|
||||||
def count_ops(scene, plan) -> dict:
|
def count_ops(scene, plan, quantity: int = 1) -> dict:
|
||||||
"""Deterministic operation counts off the scene + cut plan."""
|
"""Deterministic operation counts off the scene + cut plan. For a batch of
|
||||||
|
N units, scene-derived counts (joints/features/finish) scale by N; `cuts`
|
||||||
|
comes from the plan, which already reflects N when built with quantity=N."""
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
|
q = max(1, quantity)
|
||||||
feats = Counter(f.kind for p in scene.parts for f in p.features)
|
feats = Counter(f.kind for p in scene.parts for f in p.features)
|
||||||
return {
|
return {
|
||||||
"parts": len(scene.parts),
|
"parts": len(scene.parts) * q,
|
||||||
"cuts": len(plan.items), # one crosscut per cut item
|
"cuts": len(plan.items), # plan already ×N for a batch
|
||||||
"butt_joints": len(scene.joints),
|
"butt_joints": len(scene.joints) * q,
|
||||||
"connections": len(scene.connections),
|
"connections": len(scene.connections) * q,
|
||||||
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS),
|
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS) * q,
|
||||||
"finished_parts": sum(1 for p in scene.parts if p.finish != "raw"),
|
"finished_parts": sum(1 for p in scene.parts if p.finish != "raw") * q,
|
||||||
"features": dict(feats),
|
"features": {k: v * q for k, v in feats.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,11 +125,20 @@ class ProjectEstimate:
|
||||||
labor_lines: list # list[Line] (time breakdown, cost per group)
|
labor_lines: list # list[Line] (time breakdown, cost per group)
|
||||||
labor_minutes: float
|
labor_minutes: float
|
||||||
rates: EstimateRates
|
rates: EstimateRates
|
||||||
|
quantity: int = 1 # how many units this estimate covers
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def material_cost(self) -> float:
|
def material_cost(self) -> float:
|
||||||
return self.material.total # includes HST — what you pay
|
return self.material.total # includes HST — what you pay
|
||||||
|
|
||||||
|
@property
|
||||||
|
def per_unit_cost(self) -> float:
|
||||||
|
return round(self.total_cost / max(1, self.quantity), 2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def per_unit_price(self) -> float:
|
||||||
|
return round(self.price / max(1, self.quantity), 2)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def consumable_cost(self) -> float:
|
def consumable_cost(self) -> float:
|
||||||
return round(sum(l.cost for l in self.consumables), 2)
|
return round(sum(l.cost for l in self.consumables), 2)
|
||||||
|
|
@ -160,9 +172,10 @@ class ProjectEstimate:
|
||||||
|
|
||||||
|
|
||||||
def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None,
|
def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None,
|
||||||
hst: float = prices_mod.NB_HST) -> ProjectEstimate:
|
hst: float = prices_mod.NB_HST, quantity: int = 1) -> ProjectEstimate:
|
||||||
rates = rates or load_rates()
|
rates = rates or load_rates()
|
||||||
ops = count_ops(scene, plan)
|
q = max(1, quantity)
|
||||||
|
ops = count_ops(scene, plan, q)
|
||||||
material = prices_mod.estimate(plan, prices, hst=hst)
|
material = prices_mod.estimate(plan, prices, hst=hst)
|
||||||
|
|
||||||
# --- consumables ---
|
# --- consumables ---
|
||||||
|
|
@ -176,12 +189,12 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
|
||||||
if glue_oz:
|
if glue_oz:
|
||||||
consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/oz",
|
consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/oz",
|
||||||
round(glue_oz * rates.glue_cost_per_oz, 2)))
|
round(glue_oz * rates.glue_cost_per_oz, 2)))
|
||||||
# finish material: per part, priced by its finish kind × surface area
|
# finish material: per part, priced by its finish kind × surface area (×N units)
|
||||||
finish_cost, finish_sqft, kinds = 0.0, 0.0, set()
|
finish_cost, finish_sqft, kinds = 0.0, 0.0, set()
|
||||||
for p in scene.parts:
|
for p in scene.parts:
|
||||||
if p.finish == "raw":
|
if p.finish == "raw":
|
||||||
continue
|
continue
|
||||||
a = _part_sqft(p)
|
a = _part_sqft(p) * q
|
||||||
finish_sqft += a
|
finish_sqft += a
|
||||||
finish_cost += a * rates.finish_cost_per_sqft.get(p.finish, 0.0)
|
finish_cost += a * rates.finish_cost_per_sqft.get(p.finish, 0.0)
|
||||||
kinds.add(p.finish)
|
kinds.add(p.finish)
|
||||||
|
|
@ -202,7 +215,7 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
|
||||||
("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection,
|
("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection,
|
||||||
f"{ops['connections']} connection(s)"),
|
f"{ops['connections']} connection(s)"),
|
||||||
("Sanding / finishing",
|
("Sanding / finishing",
|
||||||
sum(rates.min_per_finish.get(p.finish, 0.0) for p in scene.parts if p.finish != "raw"),
|
sum(rates.min_per_finish.get(p.finish, 0.0) for p in scene.parts if p.finish != "raw") * q,
|
||||||
f"{ops['finished_parts']} part(s)"),
|
f"{ops['finished_parts']} part(s)"),
|
||||||
]
|
]
|
||||||
for kind, n in sorted(ops["features"].items()):
|
for kind, n in sorted(ops["features"].items()):
|
||||||
|
|
@ -216,7 +229,7 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
|
||||||
|
|
||||||
return ProjectEstimate(material=material, consumables=consumables,
|
return ProjectEstimate(material=material, consumables=consumables,
|
||||||
labor_lines=labor_lines, labor_minutes=round(total_min, 1),
|
labor_lines=labor_lines, labor_minutes=round(total_min, 1),
|
||||||
rates=rates)
|
rates=rates, quantity=q)
|
||||||
|
|
||||||
|
|
||||||
def _money(v: float) -> str:
|
def _money(v: float) -> str:
|
||||||
|
|
@ -225,7 +238,8 @@ def _money(v: float) -> str:
|
||||||
|
|
||||||
def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
|
def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
|
||||||
hrs = est.labor_minutes / 60.0
|
hrs = est.labor_minutes / 60.0
|
||||||
lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%)",
|
batch = f" · batch of {est.quantity}" if est.quantity > 1 else ""
|
||||||
|
lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%){batch}",
|
||||||
"editable estimate — verify before quoting", ""]
|
"editable estimate — verify before quoting", ""]
|
||||||
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
|
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
|
||||||
for l in est.consumables:
|
for l in est.consumables:
|
||||||
|
|
@ -247,4 +261,8 @@ def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
|
||||||
lines += [" " + "=" * 42,
|
lines += [" " + "=" * 42,
|
||||||
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
|
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
|
||||||
f" {'(profit ' + _money(est.profit) + ')':<30}"]
|
f" {'(profit ' + _money(est.profit) + ')':<30}"]
|
||||||
|
if est.quantity > 1:
|
||||||
|
lines += [" " + "-" * 42,
|
||||||
|
f" {'PER UNIT cost':<30}{_money(est.per_unit_cost):>12}",
|
||||||
|
f" {'PER UNIT price':<30}{_money(est.per_unit_price):>12}"]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormL
|
||||||
QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
|
QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
|
||||||
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
|
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
|
||||||
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
|
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
|
||||||
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
|
QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget,
|
||||||
QVBoxLayout, QWidget)
|
QTextEdit, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
|
|
@ -74,6 +74,7 @@ class BomWindow(QDialog):
|
||||||
self.resize(820, 640)
|
self.resize(820, 640)
|
||||||
self._order = 0
|
self._order = 0
|
||||||
self._optimized = False
|
self._optimized = False
|
||||||
|
self._quantity = 1
|
||||||
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
|
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
|
||||||
self._px = _PX
|
self._px = _PX
|
||||||
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
||||||
|
|
@ -90,10 +91,28 @@ class BomWindow(QDialog):
|
||||||
tabs.addTab(self._layout_tab(), "Cut Layout")
|
tabs.addTab(self._layout_tab(), "Cut Layout")
|
||||||
tabs.addTab(self._instructions_tab(), "Instructions")
|
tabs.addTab(self._instructions_tab(), "Instructions")
|
||||||
tabs.addTab(self._jigs_tab(), "Jigs")
|
tabs.addTab(self._jigs_tab(), "Jigs")
|
||||||
|
|
||||||
|
# header: build quantity (nests all units together → real per-unit cost)
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.addWidget(QLabel("Build units:"))
|
||||||
|
self._qty_spin = QSpinBox()
|
||||||
|
self._qty_spin.setRange(1, 999)
|
||||||
|
self._qty_spin.setValue(self._quantity)
|
||||||
|
self._qty_spin.setToolTip("Nest this many identical units together so offcuts "
|
||||||
|
"carry across units")
|
||||||
|
self._qty_spin.valueChanged.connect(self._on_quantity_changed)
|
||||||
|
header.addWidget(self._qty_spin)
|
||||||
|
header.addStretch()
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
root.addLayout(header)
|
||||||
root.addWidget(tabs)
|
root.addWidget(tabs)
|
||||||
self._refresh_all()
|
self._refresh_all()
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
# ----- one active plan; all tabs render from it ---------------------
|
# ----- one active plan; all tabs render from it ---------------------
|
||||||
def _set_plan(self, plan) -> None:
|
def _set_plan(self, plan) -> None:
|
||||||
recompute(plan) # keep waste/score truthful after any change
|
recompute(plan) # keep waste/score truthful after any change
|
||||||
|
|
@ -224,7 +243,8 @@ class BomWindow(QDialog):
|
||||||
return w
|
return w
|
||||||
|
|
||||||
def _cost_text(self) -> str:
|
def _cost_text(self) -> str:
|
||||||
est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, self._rates)
|
est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices,
|
||||||
|
self._rates, quantity=self._quantity)
|
||||||
return estimate_mod.format_estimate(est)
|
return estimate_mod.format_estimate(est)
|
||||||
|
|
||||||
def _on_margin_changed(self, value: float) -> None:
|
def _on_margin_changed(self, value: float) -> None:
|
||||||
|
|
@ -400,7 +420,7 @@ 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))
|
self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity))
|
||||||
self._status.setText("✓ optimized")
|
self._status.setText("✓ optimized")
|
||||||
|
|
||||||
def _best_of_n(self) -> None:
|
def _best_of_n(self) -> None:
|
||||||
|
|
@ -413,7 +433,7 @@ 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))
|
self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity))
|
||||||
self._status.setText("✓ best of 100 attempts")
|
self._status.setText("✓ best of 100 attempts")
|
||||||
|
|
||||||
def _next_arrangement(self) -> None:
|
def _next_arrangement(self) -> None:
|
||||||
|
|
@ -421,7 +441,7 @@ 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))
|
else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity))
|
||||||
self._set_plan(plan)
|
self._set_plan(plan)
|
||||||
|
|
||||||
def _draw_layout(self) -> None:
|
def _draw_layout(self) -> None:
|
||||||
|
|
@ -430,7 +450,12 @@ class BomWindow(QDialog):
|
||||||
self._rows = []
|
self._rows = []
|
||||||
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
|
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
|
||||||
part_of = {it.id: it.part_id for it in plan.items}
|
part_of = {it.id: it.part_id for it in plan.items}
|
||||||
label = lambda iid: names.get(part_of.get(iid, ""), iid)
|
unit_of = {it.id: getattr(it, "unit", 1) for it in plan.items}
|
||||||
|
multi = any(u > 1 for u in unit_of.values())
|
||||||
|
|
||||||
|
def label(iid):
|
||||||
|
base = names.get(part_of.get(iid, ""), iid)
|
||||||
|
return f"U{unit_of.get(iid, 1)} {base}" if multi else base
|
||||||
px, y, bar = self._px, 30.0, 34.0
|
px, y, bar = self._px, 30.0, 34.0
|
||||||
|
|
||||||
sc = plan.score
|
sc = plan.score
|
||||||
|
|
|
||||||
|
|
@ -328,3 +328,32 @@ def test_allowance_roundtrips_in_plan_json():
|
||||||
plan = build_cut_plan(s)
|
plan = build_cut_plan(s)
|
||||||
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
|
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
|
||||||
assert plan2.items[0].final_len == plan.items[0].final_len
|
assert plan2.items[0].final_len == plan.items[0].final_len
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_replicates_cut_items_with_units():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 30)
|
||||||
|
s.place("2x4", 30)
|
||||||
|
plan = build_cut_plan(s, quantity=3)
|
||||||
|
assert len(plan.items) == 6 # 2 parts × 3 units
|
||||||
|
assert {it.unit for it in plan.items} == {1, 2, 3}
|
||||||
|
assert validate_cut_plan(plan) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_shares_offcuts_across_units():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 30) # 3 fit per 96" stick
|
||||||
|
one = build_cut_plan(s, quantity=1).score["stock_count"]
|
||||||
|
three = build_cut_plan(s, quantity=3).score["stock_count"]
|
||||||
|
assert one == 1 and three == 1 # 3×30" still one stick, not three
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_estimate_per_unit():
|
||||||
|
from woodshop import estimate as E
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 30)
|
||||||
|
plan = build_cut_plan(s, quantity=4)
|
||||||
|
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=E.EstimateRates(), quantity=4)
|
||||||
|
assert est.quantity == 4
|
||||||
|
assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01
|
||||||
|
assert abs(est.per_unit_price - est.price / 4) < 0.01
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue